From dbd40fe7d3219cb69f39627637bbd6d85d88cc59 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Wed, 22 Apr 2026 23:03:51 +0200 Subject: [PATCH] Various improvements --- .../Telegram-iOS/en.lproj/Localizable.strings | 6 +- .../Sources/BrowserInstantPageContent.swift | 418 +++- .../BrowserUI/Sources/BrowserMarkdown.swift | 1143 +++++++++ .../BrowserUI/Sources/BrowserPdfContent.swift | 2 +- .../BrowserUI/Sources/BrowserScreen.swift | 249 +- .../Sources/BrowserToolbarComponent.swift | 53 +- submodules/BrowserUI/Sources/Utils.swift | 2 - .../Sources/Node/ChatListItem.swift | 175 +- .../Sources/Node/ChatListItemStrings.swift | 12 +- .../ReactionListContextMenuContent.swift | 63 +- .../Sources/ResizableSheetComponent.swift | 373 ++- .../ContextUI/Sources/ContextController.swift | 7 + .../Sources/InstantPageContentNode.swift | 24 +- .../Sources/InstantPageControllerNode.swift | 407 +++- .../Sources/InstantPageImageItem.swift | 2 +- .../Sources/InstantPageImageNode.swift | 239 +- .../Sources/InstantPageMedia.swift | 25 + .../Sources/InstantPageNode.swift | 5 + .../Sources/InstantPageSlideshowItem.swift | 3 +- .../InstantPageSlideshowItemNode.swift | 21 +- .../Sources/InstantPageSubContentNode.swift | 20 +- .../Sources/InstantPageTileNode.swift | 13 +- submodules/LocationUI/BUILD | 1 + .../Sources/LocationInfoListItem.swift | 2 +- .../Sources/LocationMapHeaderNode.swift | 30 +- .../Sources/LocationViewController.swift | 77 +- .../Sources/LocationViewControllerNode.swift | 166 +- .../Sources/MediaPickerScreen.swift | 3 - submodules/MimeTypes/Sources/TGMimeTypeMap.m | 1 + .../Sources/SharePeersContainerNode.swift | 15 +- .../Sources/SparseItemGridScrollingArea.swift | 2 +- .../Resources/PresentationResourceKey.swift | 6 + .../PresentationResourcesChatList.swift | 36 + submodules/TelegramUI/BUILD | 2 + .../Components/AdminUserActionsSheet/BUILD | 1 + .../AdminUserActionsPeerComponent.swift | 2 +- .../Sources/AdminUserActionsSheet.swift | 2075 +++++++++-------- .../Sources/ButtonComponent.swift | 74 +- .../ChatMessagePollBubbleContentNode.swift | 809 ++++--- .../ChatMessageTodoBubbleContentNode.swift | 8 +- .../ChatEntityKeyboardInputNode/BUILD | 1 + .../Sources/PaneSearchBarNode.swift | 500 ---- .../Sources/PaneSearchContainerNode.swift | 97 +- .../ChatScheduleTimeController/BUILD | 2 + .../Sources/ChatScheduleTimeScreen.swift | 186 +- .../Sources/ComposeTodoScreen.swift | 6 + .../Sources/ContextActionsContainerNode.swift | 7 + .../Sources/ListActionItemComponent.swift | 19 +- .../ListComposePollOptionComponent.swift | 17 +- .../ListItems/PeerInfoScreenCommentItem.swift | 6 +- .../Sources/PeerInfoSettingsItems.swift | 41 +- .../CallIncomingIcon.imageset/Contents.json | 12 + .../incoming_call_20.pdf | Bin 0 -> 6022 bytes .../CallOutgoingIcon.imageset/Contents.json | 12 + .../outgoing_call_20.pdf | Bin 0 -> 5988 bytes .../Contents.json | 12 + .../incoming_video_call_20.pdf | Bin 0 -> 6040 bytes .../Contents.json | 12 + .../outgoing_video_call_20.pdf | Bin 0 -> 6059 bytes .../Chat List/GameIcon.imageset/Contents.json | 12 + .../GameIcon.imageset/gamepad_20.pdf | Bin 0 -> 6049 bytes .../Chat List/TodoIcon.imageset/Contents.json | 12 + .../TodoIcon.imageset/checklist_20.pdf | Bin 0 -> 5807 bytes .../Animations/NavigationMuteOff.tgs | Bin 0 -> 1246 bytes .../Resources/Animations/NavigationMuteOn.tgs | Bin 0 -> 1237 bytes .../TelegramUI/Sources/AppDelegate.swift | 8 + .../TelegramUI/Sources/ChatController.swift | 2 +- .../Sources/ChatControllerAdminBanUsers.swift | 31 +- .../Sources/ChatControllerNode.swift | 2 +- ...rollerOpenMessageReactionContextMenu.swift | 34 +- .../ChatControllerOpenMessageShareMenu.swift | 53 + .../ChatInterfaceStateContextMenus.swift | 24 + .../TelegramUI/Sources/OpenChatMessage.swift | 9 +- .../TelegramUI/Sources/OpenResolvedUrl.swift | 2 +- .../Sources/SharedAccountContext.swift | 4 +- .../Sources/StringWithAppliedEntities.swift | 116 +- 76 files changed, 5186 insertions(+), 2625 deletions(-) create mode 100644 submodules/BrowserUI/Sources/BrowserMarkdown.swift delete mode 100644 submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchBarNode.swift create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/incoming_call_20.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/outgoing_call_20.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallVideoIncomingIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallVideoIncomingIcon.imageset/incoming_video_call_20.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallVideoOutgoingIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/CallVideoOutgoingIcon.imageset/outgoing_video_call_20.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/GameIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/GameIcon.imageset/gamepad_20.pdf create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/TodoIcon.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Chat List/TodoIcon.imageset/checklist_20.pdf create mode 100644 submodules/TelegramUI/Resources/Animations/NavigationMuteOff.tgs create mode 100644 submodules/TelegramUI/Resources/Animations/NavigationMuteOn.tgs diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index c6e9ae24b3..71e02d506c 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16202,5 +16202,9 @@ Error: %8$@"; "Settings.About.PrivacyHelpEmpty" = "A few words about you."; "Settings.About.PrivacyHelpEveryone" = "Everyone can see your bio. [Change >]()"; -"Settings.About.PrivacyHelpContacts" = "Only uour contacts can see your bio. [Change >]()"; +"Settings.About.PrivacyHelpContacts" = "Only your contacts can see your bio. [Change >]()"; "Settings.About.PrivacyHelpNobody" = "Nobody can see your bio. [Change >]()"; + +"Settings.Birthday.PrivacyHelpEveryone" = "Everyone can see your birthday. [Change >]()"; +"Settings.Birthday.PrivacyHelpContacts" = "Only your contacts can see your birthday. [Change >]()"; +"Settings.Birthday.PrivacyHelpNobody" = "Nobody can see your birthday. [Change >]()"; diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index ec69d0ec82..4f9c55b877 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -65,6 +65,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg var currentWebEmbedHeights: [Int : CGFloat] = [:] var currentExpandedDetails: [Int : Bool]? var currentDetailsItems: [InstantPageDetailsItem] = [] + private var resolvedExternalMediaDimensions: [MediaId: PixelDimensions] = [:] + private var pendingResolvedExternalMediaDimensions = Set() var currentAccessibilityAreas: [AccessibilityAreaNode] = [] @@ -87,6 +89,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg private let loadWebpageDisposable = MetaDisposable() private let resolveUrlDisposable = MetaDisposable() private let updateLayoutDisposable = MetaDisposable() + private let updateExternalMediaDimensionsDisposable = MetaDisposable() private let loadProgress = ValuePromise(1.0, ignoreRepeated: true) private let readingProgress = ValuePromise(0.0, ignoreRepeated: true) @@ -179,12 +182,14 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } self.scrollNode.view.addGestureRecognizer(recognizer) - self.webpageDisposable = (actualizedWebpage(account: context.account, webpage: webPage) |> deliverOnMainQueue).start(next: { [weak self] result in - guard let self else { - return - } - self.updateWebPage(result, anchor: self.initialAnchor) - }) + if case let .Loaded(content) = webPage.content, let scheme = URL(string: content.url)?.scheme?.lowercased(), scheme == "http" || scheme == "https" { + self.webpageDisposable = (actualizedWebpage(account: context.account, webpage: webPage) |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + self.updateWebPage(result, anchor: self.initialAnchor) + }) + } } deinit { @@ -193,6 +198,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg self.loadWebpageDisposable.dispose() self.resolveUrlDisposable.dispose() self.updateLayoutDisposable.dispose() + self.updateExternalMediaDimensionsDisposable.dispose() } required init?(coder: NSCoder) { @@ -304,6 +310,8 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } else { self.webPage = nil } + self.resolvedExternalMediaDimensions.removeAll() + self.pendingResolvedExternalMediaDimensions.removeAll() if let anchor = anchor { self.initialAnchor = anchor.removingPercentEncoding } else if let state = state { @@ -478,17 +486,12 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } private func updatePageLayout() { - guard let (size, insets, _) = self.containerLayout, let (webPage, instantPage) = self.webPage else { + guard let (size, insets, _) = self.containerLayout, let (webPage, instantPage) = self.resolvedWebPage() else { return } let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: size.width, safeInset: insets.left, strings: self.presentationData.strings, theme: self.theme, dateTimeFormat: self.presentationData.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) - for (_, tileNode) in self.visibleTiles { - tileNode.removeFromSupernode() - } - self.visibleTiles.removeAll() - let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: size.width) var currentDetailsItems: [InstantPageDetailsItem] = [] @@ -655,6 +658,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg topNode = newNode self.visibleItemsWithNodes[itemIndex] = newNode itemNode = newNode + self.configureExternalMediaDimensionsUpdates(for: newNode) if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.requestLayoutUpdate = { [weak self] animated in @@ -678,6 +682,10 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg } } + if let itemNode = itemNode { + self.configureExternalMediaDimensionsUpdates(for: itemNode) + } + if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated) } @@ -708,8 +716,11 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg topNode = tileNode self.visibleTiles[tileIndex] = tileNode } else { - if visibleTiles[tileIndex]!.frame != tileFrame { - transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame) + if let tileNode = self.visibleTiles[tileIndex] { + tileNode.update(tile: tile, backgroundColor: theme.pageBackgroundColor) + if tileNode.frame != tileFrame { + transition.updateFrame(node: tileNode, frame: tileFrame) + } } } } @@ -929,6 +940,385 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg return nil } + private func configureExternalMediaDimensionsUpdates(for itemNode: InstantPageNode) { + let update: (MediaId, PixelDimensions) -> Void = { [weak self] mediaId, dimensions in + self?.updateExternalMediaDimensions(mediaId, dimensions) + } + if let itemNode = itemNode as? InstantPageExternalMediaDimensionsNode { + itemNode.updateExternalMediaDimensions = update + } + if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.contentNode.updateExternalMediaDimensions = update + } + } + + private func updateExternalMediaDimensions(_ mediaId: MediaId, _ dimensions: PixelDimensions) { + if self.resolvedExternalMediaDimensions[mediaId] == dimensions { + return + } + self.resolvedExternalMediaDimensions[mediaId] = dimensions + self.pendingResolvedExternalMediaDimensions.insert(mediaId) + + let signal: Signal = (.complete() |> delay(0.08, queue: Queue.mainQueue())) + self.updateExternalMediaDimensionsDisposable.set(signal.start(completed: { [weak self] in + self?.relayoutForResolvedExternalMediaDimensions() + })) + } + + private func relayoutForResolvedExternalMediaDimensions() { + guard !self.pendingResolvedExternalMediaDimensions.isEmpty else { + return + } + + let mediaIds = Array(self.pendingResolvedExternalMediaDimensions) + self.pendingResolvedExternalMediaDimensions.removeAll() + + let detailsStateMaps = self.captureExpandedDetailsStateMaps() + let viewportTop = self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentInset.top + var oldFrames: [MediaId: CGRect] = [:] + for mediaId in mediaIds { + if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) { + oldFrames[mediaId] = frame + } + } + + self.updatePageLayout() + + var newFrames: [MediaId: CGRect] = [:] + for mediaId in mediaIds { + if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) { + newFrames[mediaId] = frame + } + } + + if let compensatedViewportTop = self.compensatedViewportTop(oldFrames: oldFrames, newFrames: newFrames, viewportTop: viewportTop) { + self.setViewportTop(compensatedViewportTop) + } + self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) + } + + private func setViewportTop(_ viewportTop: CGFloat) { + let scrollView = self.scrollNode.view + let minOffsetY = -scrollView.contentInset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom) + let contentOffsetY = min(max(viewportTop - scrollView.contentInset.top, minOffsetY), maxOffsetY) + if contentOffsetY.isFinite { + scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: contentOffsetY) + } + } + + private func compensatedViewportTop(oldFrames: [MediaId: CGRect], newFrames: [MediaId: CGRect], viewportTop: CGFloat) -> CGFloat? { + var pairedFrames: [(old: CGRect, new: CGRect)] = [] + for (mediaId, oldFrame) in oldFrames { + if let newFrame = newFrames[mediaId] { + pairedFrames.append((oldFrame, newFrame)) + } + } + if pairedFrames.isEmpty { + return nil + } + + if let intersecting = pairedFrames + .filter({ $0.old.height > 0.0 && $0.new.height > 0.0 && viewportTop > $0.old.minY && viewportTop < $0.old.maxY }) + .max(by: { $0.old.minY < $1.old.minY }) { + let ratio = min(max((viewportTop - intersecting.old.minY) / intersecting.old.height, 0.0), 1.0) + return intersecting.new.minY + ratio * intersecting.new.height + } + + if let above = pairedFrames + .filter({ viewportTop >= $0.old.maxY }) + .max(by: { $0.old.maxY < $1.old.maxY }) { + return viewportTop + (above.new.maxY - above.old.maxY) + } + + return nil + } + + private func captureExpandedDetailsStateMaps() -> [String: [Int: Bool]] { + guard let currentLayout = self.currentLayout else { + return [:] + } + var result: [String: [Int: Bool]] = [:] + self.captureExpandedDetailsStateMaps(items: currentLayout.items, visibleItemsWithNodes: self.visibleItemsWithNodes, path: [], result: &result) + return result + } + + private func captureExpandedDetailsStateMaps(items: [InstantPageItem], visibleItemsWithNodes: [Int: InstantPageNode], path: [Int], result: inout [String: [Int: Bool]]) { + let detailsNodes = visibleItemsWithNodes.compactMap { $0.value as? InstantPageDetailsNode } + + var detailsIndex = -1 + for item in items { + guard let detailsItem = item as? InstantPageDetailsItem else { + continue + } + detailsIndex += 1 + + guard let detailsNode = detailsNodes.first(where: { $0.item === detailsItem }) else { + continue + } + let nextPath = path + [detailsIndex] + result[self.detailsStateKey(nextPath)] = detailsNode.contentNode.currentExpandedDetails ?? [:] + self.captureExpandedDetailsStateMaps(items: detailsItem.items, visibleItemsWithNodes: detailsNode.contentNode.visibleItemsWithNodes, path: nextPath, result: &result) + } + } + + private func detailsStateKey(_ path: [Int]) -> String { + if path.isEmpty { + return "" + } + return path.map(String.init).joined(separator: ".") + } + + private func effectiveFrameForMedia(_ mediaId: MediaId, detailsStateMaps: [String: [Int: Bool]]) -> CGRect? { + guard let currentLayout = self.currentLayout else { + return nil + } + return self.effectiveFrameForMedia(mediaId, items: currentLayout.items, origin: .zero, expandedDetails: self.currentExpandedDetails, path: [], detailsStateMaps: detailsStateMaps) + } + + private func effectiveFrameForMedia(_ mediaId: MediaId, items: [InstantPageItem], origin: CGPoint, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGRect? { + var collapseOffset: CGFloat = 0.0 + var detailsIndex = -1 + + for item in items { + if item is InstantPageDetailsItem { + detailsIndex += 1 + } + + var itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y - collapseOffset) + if let detailsItem = item as? InstantPageDetailsItem { + let nextPath = path + [detailsIndex] + let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)] + let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded + let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight + collapseOffset += item.frame.height - height + itemFrame.size.height = height + + if expanded, let nestedFrame = self.effectiveFrameForMedia(mediaId, items: detailsItem.items, origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY + detailsItem.titleHeight), expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) { + return nestedFrame + } + continue + } + + if self.itemContainsMedia(item, mediaId: mediaId) { + return itemFrame + } + } + + return nil + } + + private func effectiveContentHeight(items: [InstantPageItem], baseHeight: CGFloat, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGFloat { + var contentHeight = baseHeight + var detailsIndex = -1 + + for item in items { + guard let detailsItem = item as? InstantPageDetailsItem else { + continue + } + detailsIndex += 1 + + let nextPath = path + [detailsIndex] + let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)] + let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded + let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight + contentHeight += -detailsItem.frame.height + height + } + + return contentHeight + } + + private func itemContainsMedia(_ item: InstantPageItem, mediaId: MediaId) -> Bool { + for media in item.medias { + if media.media.id == mediaId { + return true + } + } + return false + } + + private func resolvedWebPage() -> (webPage: TelegramMediaWebpage, instantPage: InstantPage?)? { + guard let (webPage, instantPage) = self.webPage else { + return nil + } + guard !self.resolvedExternalMediaDimensions.isEmpty, case let .Loaded(content) = webPage.content else { + return (webPage, instantPage) + } + + var instantPageUpdated = false + var effectiveInstantPage = instantPage + if let instantPage { + var media = instantPage.media + for (mediaId, currentMedia) in instantPage.media { + if let updatedMedia = self.updatedMediaIfNeeded(currentMedia) { + media[mediaId] = updatedMedia + instantPageUpdated = true + } + } + if instantPageUpdated { + effectiveInstantPage = InstantPage(blocks: instantPage.blocks, media: media, isComplete: instantPage.isComplete, rtl: instantPage.rtl, url: instantPage.url, views: instantPage.views) + } + } + + var imageUpdated = false + let effectiveImage = content.image.map { image -> TelegramMediaImage in + if let updated = self.updatedImageIfNeeded(image) { + imageUpdated = true + return updated + } else { + return image + } + } + + var fileUpdated = false + let effectiveFile = content.file.map { file -> TelegramMediaFile in + if let updated = self.updatedFileIfNeeded(file) { + fileUpdated = true + return updated + } else { + return file + } + } + + if !instantPageUpdated && !imageUpdated && !fileUpdated { + return (webPage, instantPage) + } + + let effectiveContent = TelegramMediaWebpageLoadedContent( + url: content.url, + displayUrl: content.displayUrl, + hash: content.hash, + type: content.type, + websiteName: content.websiteName, + title: content.title, + text: content.text, + embedUrl: content.embedUrl, + embedType: content.embedType, + embedSize: content.embedSize, + duration: content.duration, + author: content.author, + isMediaLargeByDefault: content.isMediaLargeByDefault, + imageIsVideoCover: content.imageIsVideoCover, + image: effectiveImage, + file: effectiveFile, + story: content.story, + attributes: content.attributes, + instantPage: effectiveInstantPage + ) + return (TelegramMediaWebpage(webpageId: webPage.webpageId, content: .Loaded(effectiveContent)), effectiveInstantPage) + } + + private func updatedMediaIfNeeded(_ media: Media) -> Media? { + if let image = media as? TelegramMediaImage { + return self.updatedImageIfNeeded(image) + } else if let file = media as? TelegramMediaFile { + return self.updatedFileIfNeeded(file) + } else { + return nil + } + } + + private func updatedImageIfNeeded(_ image: TelegramMediaImage) -> TelegramMediaImage? { + guard let dimensions = self.resolvedExternalMediaDimensions[image.imageId] else { + return nil + } + + var updatedRepresentations = image.representations + var didUpdate = false + for i in 0 ..< updatedRepresentations.count { + let representation = updatedRepresentations[i] + guard representation.resource is InstantPageExternalMediaResource, representation.dimensions != dimensions else { + continue + } + updatedRepresentations[i] = TelegramMediaImageRepresentation( + dimensions: dimensions, + resource: representation.resource, + progressiveSizes: representation.progressiveSizes, + immediateThumbnailData: representation.immediateThumbnailData, + hasVideo: representation.hasVideo, + isPersonal: representation.isPersonal, + typeHint: representation.typeHint + ) + didUpdate = true + } + + guard didUpdate else { + return nil + } + return TelegramMediaImage( + imageId: image.imageId, + representations: updatedRepresentations, + videoRepresentations: image.videoRepresentations, + immediateThumbnailData: image.immediateThumbnailData, + emojiMarkup: image.emojiMarkup, + reference: image.reference, + partialReference: image.partialReference, + flags: image.flags, + video: image.video + ) + } + + private func updatedFileIfNeeded(_ file: TelegramMediaFile) -> TelegramMediaFile? { + guard let dimensions = self.resolvedExternalMediaDimensions[file.fileId], file.resource is InstantPageExternalMediaResource else { + return nil + } + + let (attributes, didUpdate) = self.fileAttributesWithResolvedDimensions(file.attributes, dimensions: dimensions) + guard didUpdate else { + return nil + } + + return TelegramMediaFile( + fileId: file.fileId, + partialReference: file.partialReference, + resource: file.resource, + previewRepresentations: file.previewRepresentations, + videoThumbnails: file.videoThumbnails, + videoCover: file.videoCover, + immediateThumbnailData: file.immediateThumbnailData, + mimeType: file.mimeType, + size: file.size, + attributes: attributes, + alternativeRepresentations: file.alternativeRepresentations + ) + } + + private func fileAttributesWithResolvedDimensions(_ attributes: [TelegramMediaFileAttribute], dimensions: PixelDimensions) -> ([TelegramMediaFileAttribute], Bool) { + var updatedAttributes: [TelegramMediaFileAttribute] = [] + var didUpdate = false + var hasSizeAttribute = false + + for attribute in attributes { + switch attribute { + case let .ImageSize(size): + hasSizeAttribute = true + if size != dimensions { + updatedAttributes.append(.ImageSize(size: dimensions)) + didUpdate = true + } else { + updatedAttributes.append(attribute) + } + case let .Video(duration, size, flags, preloadSize, coverTime, videoCodec): + hasSizeAttribute = true + if size != dimensions { + updatedAttributes.append(.Video(duration: duration, size: dimensions, flags: flags, preloadSize: preloadSize, coverTime: coverTime, videoCodec: videoCodec)) + didUpdate = true + } else { + updatedAttributes.append(attribute) + } + default: + updatedAttributes.append(attribute) + } + } + + if !hasSizeAttribute { + updatedAttributes.append(.ImageSize(size: dimensions)) + didUpdate = true + } + + return (updatedAttributes, didUpdate) + } + private func openUrl(_ url: InstantPageUrlItem) { var baseUrl = url.url var anchor: String? diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift new file mode 100644 index 0000000000..3f09ec5cd4 --- /dev/null +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -0,0 +1,1143 @@ +import Foundation +import UIKit +import Postbox +import TelegramCore +import AccountContext +import InstantPageUI + +private let markdownPresentationIntentAttribute = NSAttributedString.Key("NSPresentationIntent") +private let markdownInlinePresentationIntentAttribute = NSAttributedString.Key("NSInlinePresentationIntent") +private let markdownLinkAttribute = NSAttributedString.Key("NSLink") +private let markdownImageURLAttribute = NSAttributedString.Key("NSImageURL") +private let markdownAlternateDescriptionAttribute = NSAttributedString.Key("NSAlternateDescription") + +@available(iOS 15.0, *) +private let markdownSoftBreakInlineIntent = InlinePresentationIntent(rawValue: 1 << 6) +@available(iOS 15.0, *) +private let markdownHardBreakInlineIntent = InlinePresentationIntent(rawValue: 1 << 7) +@available(iOS 15.0, *) +private let markdownInlineHTMLInlineIntent = InlinePresentationIntent(rawValue: 1 << 8) + +private let markdownDefaultBlockImageDimensions = PixelDimensions(width: 1200, height: 900) +private let markdownDefaultInlineImageDimensions = PixelDimensions(width: 18, height: 18) + +private struct MarkdownPageResult { + let blocks: [InstantPageBlock] + let media: [MediaId: Media] +} + +private enum MarkdownInlineFragment { + case richText(RichText) + case image(MarkdownResolvedImage) +} + +private struct MarkdownInlineContent { + let fragments: [MarkdownInlineFragment] + + var richText: RichText { + var result: [RichText] = [] + result.reserveCapacity(self.fragments.count) + + for fragment in self.fragments { + switch fragment { + case let .richText(text): + result.append(text) + case let .image(image): + var text: RichText = .image(id: image.mediaId, dimensions: image.inlineDimensions) + if let linkUrl = image.linkUrl { + text = .url(text: text, url: linkUrl, webpageId: nil) + } + result.append(text) + } + } + + return markdownCompact(result) + } + + var standaloneImage: MarkdownResolvedImage? { + var result: MarkdownResolvedImage? + + for fragment in self.fragments { + switch fragment { + case let .richText(text): + if !markdownIsWhitespaceOnly(text) { + return nil + } + case let .image(image): + if result != nil { + return nil + } + result = image + } + } + + return result + } +} + +private struct MarkdownResolvedImage { + let mediaId: MediaId + let inlineDimensions: PixelDimensions + let caption: InstantPageCaption + let linkUrl: String? +} + +private enum MarkdownResolvedImageSource { + case remote(String) + case data(Data, PixelDimensions) + case unsupported +} + +private final class MarkdownConversionContext { + private let context: AccountContext + fileprivate let documentURL: URL + private var nextRemoteMediaId: Int64 = 0 + private var nextLocalMediaId: Int64 = 0 + + private(set) var media: [MediaId: Media] = [:] + + init(context: AccountContext, documentURL: URL) { + self.context = context + self.documentURL = documentURL + } + + func makePageResult(blocks: [InstantPageBlock]) -> MarkdownPageResult { + return MarkdownPageResult(blocks: blocks, media: self.media) + } + + func resolveImage(attributes: [NSAttributedString.Key: Any]) -> MarkdownResolvedImage? { + guard let imageUrl = markdownImageURL(attributes: attributes) else { + return nil + } + + let inlineDimensions = markdownInlineImageDimensions(attributes: attributes) + let caption = markdownImageCaption(markdownAlternateDescription(attributes: attributes)) + let linkUrl = markdownLink(attributes: attributes, documentURL: self.documentURL) + + switch markdownResolveImageSource(imageUrl) { + case let .remote(url): + let mediaId = self.nextMediaId(namespace: Namespaces.Media.CloudImage) + self.media[mediaId] = TelegramMediaImage( + imageId: mediaId, + representations: [ + TelegramMediaImageRepresentation( + dimensions: markdownDefaultBlockImageDimensions, + resource: InstantPageExternalMediaResource(url: url), + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + return MarkdownResolvedImage( + mediaId: mediaId, + inlineDimensions: inlineDimensions, + caption: caption, + linkUrl: linkUrl + ) + case let .data(data, dimensions): + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.count), isSecretRelated: false) + self.context.account.postbox.mediaBox.storeResourceData(resource.id, data: data) + + let mediaId = self.nextMediaId(namespace: Namespaces.Media.LocalImage) + self.media[mediaId] = TelegramMediaImage( + imageId: mediaId, + representations: [ + TelegramMediaImageRepresentation( + dimensions: dimensions, + resource: resource, + progressiveSizes: [], + immediateThumbnailData: nil + ) + ], + immediateThumbnailData: nil, + reference: nil, + partialReference: nil, + flags: [] + ) + return MarkdownResolvedImage( + mediaId: mediaId, + inlineDimensions: inlineDimensions, + caption: caption, + linkUrl: linkUrl + ) + case .unsupported: + return nil + } + } + + private func nextMediaId(namespace: Int32) -> MediaId { + switch namespace { + case Namespaces.Media.LocalImage: + self.nextLocalMediaId += 1 + return MediaId(namespace: namespace, id: self.nextLocalMediaId) + default: + self.nextRemoteMediaId += 1 + return MediaId(namespace: namespace, id: self.nextRemoteMediaId) + } + } +} + +func markdownWebpage(context: AccountContext, file: FileMediaReference) -> (webPage: TelegramMediaWebpage, fileURL: URL)? { + guard #available(iOS 15.0, *) else { + return nil + } + guard let path = context.account.postbox.mediaBox.completedResourcePath(file.media.resource) else { + return nil + } + let fileURL = URL(fileURLWithPath: path) + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + guard let webPage = markdownWebpage(context: context, file: file, fileURL: fileURL, data: data) else { + return nil + } + return (webPage, fileURL) +} + +@available(iOS 15.0, *) +private func markdownWebpage(context: AccountContext, file: FileMediaReference, fileURL: URL, data: Data) -> TelegramMediaWebpage? { + let attributedString: NSAttributedString + do { + attributedString = try NSAttributedString( + markdown: data, + options: .init(), + baseURL: fileURL.deletingLastPathComponent() + ) + } catch { + return nil + } + + let conversionContext = MarkdownConversionContext(context: context, documentURL: fileURL) + let pageResult = markdownPageResult(from: attributedString, context: conversionContext) + let blocks = markdownBlocksWithGeneratedAnchors(pageResult.blocks) + guard !blocks.isEmpty else { + return nil + } + + let title = markdownTitle(from: blocks, file: file, fileURL: fileURL) + let text = markdownFirstParagraphText(from: blocks) + let instantPage = InstantPage( + blocks: blocks, + media: pageResult.media, + isComplete: true, + rtl: false, + url: fileURL.absoluteString, + views: nil + ) + + return TelegramMediaWebpage( + webpageId: MediaId(namespace: 0, id: 0), + content: .Loaded( + TelegramMediaWebpageLoadedContent( + url: fileURL.absoluteString, + displayUrl: fileURL.absoluteString, + hash: 0, + type: "article", + websiteName: nil, + title: title, + text: text, + embedUrl: nil, + embedType: nil, + embedSize: nil, + duration: nil, + author: nil, + isMediaLargeByDefault: nil, + imageIsVideoCover: false, + image: nil, + file: nil, + story: nil, + attributes: [], + instantPage: instantPage + ) + ) + ) +} + +@available(iOS 15.0, *) +private func markdownPageResult(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> MarkdownPageResult { + var nodesByIdentity: [Int: MarkdownIntentNode] = [:] + var rootNodes: [MarkdownIntentNode] = [] + var rootIdentities: Set = [] + + attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { attributes, range, _ in + guard range.length > 0 else { + return + } + guard let presentationIntent = attributes[markdownPresentationIntentAttribute] as? PresentationIntent else { + return + } + let components = presentationIntent.components + guard !components.isEmpty else { + return + } + + var orderedNodes: [MarkdownIntentNode] = [] + for component in components.reversed() { + let node: MarkdownIntentNode + if let current = nodesByIdentity[component.identity] { + node = current + } else { + let created = MarkdownIntentNode(component: component) + nodesByIdentity[component.identity] = created + node = created + } + orderedNodes.append(node) + } + + if let rootNode = orderedNodes.first, rootIdentities.insert(rootNode.identity).inserted { + rootNodes.append(rootNode) + } + if orderedNodes.count >= 2 { + for index in 0 ..< (orderedNodes.count - 1) { + orderedNodes[index].append(child: orderedNodes[index + 1]) + } + } + if let leafNode = orderedNodes.last { + leafNode.append(text: attributedString.attributedSubstring(from: range)) + } + } + + return context.makePageResult(blocks: markdownBlocks(from: rootNodes, context: context)) +} + +private func markdownBlocks(from nodes: [MarkdownIntentNode], context: MarkdownConversionContext) -> [InstantPageBlock] { + var result: [InstantPageBlock] = [] + for node in nodes { + result.append(contentsOf: markdownBlocks(from: node, context: context)) + } + return result +} + +private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConversionContext) -> [InstantPageBlock] { + switch node.kind { + case let .table(alignments): + let rows = markdownTableRows(from: node.children, alignments: alignments, context: context) + guard !rows.isEmpty else { + return [] + } + return [.table(title: .empty, rows: rows, bordered: true, striped: false)] + case let .header(level): + let text = markdownRichText(from: node.attributedText, context: context) + guard markdownHasDisplayableContent(text) else { + return [] + } + if level <= 1 { + return [.title(text)] + } else if level == 2 { + return [.header(text)] + } else { + return [.subheader(text)] + } + case .paragraph: + let inlineContent = markdownInlineContent(from: node.attributedText, context: context) + if let image = inlineContent.standaloneImage { + return [ + .image( + id: image.mediaId, + caption: image.caption, + url: image.linkUrl, + webpageId: nil + ) + ] + } + let text = inlineContent.richText + guard markdownHasDisplayableContent(text) else { + return [] + } + return [.paragraph(text)] + case .codeBlock: + let text = markdownRichText(from: markdownTrimTrailingCodeBlockNewline(node.attributedText), context: context) + guard markdownHasDisplayableContent(text) else { + return [] + } + return [.preformatted(text)] + case .thematicBreak: + return [.divider] + case .blockQuote: + var result: [InstantPageBlock] = [] + for child in node.children { + for childBlock in markdownBlocks(from: child, context: context) { + switch childBlock { + case let .paragraph(text): + result.append(.blockQuote(text: text, caption: .empty)) + default: + let plainText = markdownPlainText(from: childBlock) + if !plainText.isEmpty { + result.append(.blockQuote(text: .plain(plainText), caption: .empty)) + } + } + } + } + return result + case .orderedList: + let items = markdownListItems(from: node.children, ordered: true, context: context) + guard !items.isEmpty else { + return [] + } + return [.list(items: items, ordered: true)] + case .unorderedList: + let items = markdownListItems(from: node.children, ordered: false, context: context) + guard !items.isEmpty else { + return [] + } + return [.list(items: items, ordered: false)] + case .listItem(_), .tableHeaderRow, .tableRow, .tableCell(_), .unknown: + return markdownBlocks(from: node.children, context: context) + } +} + +private func markdownListItems(from nodes: [MarkdownIntentNode], ordered: Bool, context: MarkdownConversionContext) -> [InstantPageListItem] { + var result: [InstantPageListItem] = [] + for node in nodes { + guard case let .listItem(ordinal) = node.kind else { + continue + } + let blocks = markdownBlocks(from: node.children, context: context) + guard !blocks.isEmpty else { + continue + } + let number: String? + if ordered { + number = "\(ordinal)" + } else { + number = nil + } + if blocks.count == 1, case let .paragraph(text) = blocks[0] { + result.append(.text(text, number)) + } else { + result.append(.blocks(blocks, number)) + } + } + return result +} + +private func markdownTableRows(from nodes: [MarkdownIntentNode], alignments: [TableHorizontalAlignment], context: MarkdownConversionContext) -> [InstantPageTableRow] { + var result: [InstantPageTableRow] = [] + for node in nodes { + switch node.kind { + case .tableHeaderRow: + let cells = markdownTableCells(from: node.children, alignments: alignments, header: true, context: context) + if !cells.isEmpty { + result.append(InstantPageTableRow(cells: cells)) + } + case .tableRow: + let cells = markdownTableCells(from: node.children, alignments: alignments, header: false, context: context) + if !cells.isEmpty { + result.append(InstantPageTableRow(cells: cells)) + } + default: + continue + } + } + return result +} + +private func markdownTableCells(from nodes: [MarkdownIntentNode], alignments: [TableHorizontalAlignment], header: Bool, context: MarkdownConversionContext) -> [InstantPageTableCell] { + let maxColumnIndex = nodes.reduce(-1) { partialResult, node in + if case let .tableCell(column) = node.kind { + return max(partialResult, column) + } else { + return partialResult + } + } + let columnCount = max(alignments.count, maxColumnIndex + 1) + guard columnCount > 0 else { + return [] + } + + var result: [InstantPageTableCell] = [] + var nextColumn = 0 + + for node in nodes { + guard case let .tableCell(column) = node.kind else { + continue + } + + while nextColumn < column { + result.append(markdownEmptyTableCell(header: header, alignment: markdownTableAlignment(at: nextColumn, from: alignments))) + nextColumn += 1 + } + + let text = markdownRichText(from: node.attributedText, context: context) + result.append( + InstantPageTableCell( + text: text, + header: header, + alignment: markdownTableAlignment(at: column, from: alignments), + verticalAlignment: .top, + colspan: 1, + rowspan: 1 + ) + ) + nextColumn = max(nextColumn, column + 1) + } + + while nextColumn < columnCount { + result.append(markdownEmptyTableCell(header: header, alignment: markdownTableAlignment(at: nextColumn, from: alignments))) + nextColumn += 1 + } + + return result +} + +private func markdownEmptyTableCell(header: Bool, alignment: TableHorizontalAlignment) -> InstantPageTableCell { + return InstantPageTableCell( + text: .empty, + header: header, + alignment: alignment, + verticalAlignment: .top, + colspan: 1, + rowspan: 1 + ) +} + +private func markdownTableAlignment(at index: Int, from alignments: [TableHorizontalAlignment]) -> TableHorizontalAlignment { + guard index >= 0, index < alignments.count else { + return .left + } + return alignments[index] +} + +private func markdownRichText(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> RichText { + return markdownInlineContent(from: attributedString, context: context).richText +} + +private func markdownInlineContent(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> MarkdownInlineContent { + guard attributedString.length > 0, #available(iOS 15.0, *) else { + return MarkdownInlineContent(fragments: []) + } + + var fragments: [MarkdownInlineFragment] = [] + var htmlStyles: [MarkdownHTMLInlineStyle] = [] + var consumeNextSoftBreak = false + + attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { attributes, range, _ in + guard range.length > 0 else { + return + } + + let text = attributedString.attributedSubstring(from: range).string + guard !text.isEmpty else { + return + } + + let inlineIntent: InlinePresentationIntent? + if let inlineIntentValue = attributes[markdownInlinePresentationIntentAttribute] as? InlinePresentationIntent { + inlineIntent = inlineIntentValue + } else if let inlineIntentValue = attributes[markdownInlinePresentationIntentAttribute] as? NSNumber { + inlineIntent = InlinePresentationIntent(rawValue: inlineIntentValue.uintValue) + } else { + inlineIntent = nil + } + + if let inlineIntent, inlineIntent.contains(markdownInlineHTMLInlineIntent), let directive = markdownHTMLDirective(for: text) { + switch directive { + case let .open(style): + htmlStyles.append(style) + case let .close(style): + if let index = htmlStyles.lastIndex(of: style) { + htmlStyles.remove(at: index) + } + case .lineBreak: + fragments.append(.richText(markdownApplyHTMLStyles(htmlStyles, to: .plain("\n")))) + consumeNextSoftBreak = true + } + return + } + + if consumeNextSoftBreak { + if let inlineIntent, inlineIntent.contains(markdownSoftBreakInlineIntent), text == " " { + consumeNextSoftBreak = false + return + } + consumeNextSoftBreak = false + } + + if let image = context.resolveImage(attributes: attributes) { + fragments.append(.image(image)) + return + } + + var fragment: RichText = .plain(text) + if let inlineIntent { + if inlineIntent.contains(.stronglyEmphasized) { + fragment = .bold(fragment) + } + if inlineIntent.contains(.emphasized) { + fragment = .italic(fragment) + } + if inlineIntent.contains(.strikethrough) { + fragment = .strikethrough(fragment) + } + if inlineIntent.contains(.code) { + fragment = .fixed(fragment) + } + if inlineIntent.contains(markdownHardBreakInlineIntent) { + fragment = .plain("\n") + } + } + + if let url = markdownLink(attributes: attributes, documentURL: context.documentURL) { + fragment = .url(text: fragment, url: url, webpageId: nil) + } + + fragments.append(.richText(markdownApplyHTMLStyles(htmlStyles, to: fragment))) + } + + return MarkdownInlineContent(fragments: fragments) +} + +private enum MarkdownHTMLInlineStyle: Equatable { + case `subscript` + case superscript + case marked +} + +private enum MarkdownHTMLDirective { + case open(MarkdownHTMLInlineStyle) + case close(MarkdownHTMLInlineStyle) + case lineBreak +} + +private func markdownHTMLDirective(for text: String) -> MarkdownHTMLDirective? { + switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "": + return .open(.subscript) + case "": + return .close(.subscript) + case "": + return .open(.superscript) + case "": + return .close(.superscript) + case "": + return .open(.marked) + case "": + return .close(.marked) + case "
", "
", "
": + return .lineBreak + default: + return nil + } +} + +private func markdownApplyHTMLStyles(_ styles: [MarkdownHTMLInlineStyle], to text: RichText) -> RichText { + var result = text + for style in styles { + switch style { + case .subscript: + result = .subscript(result) + case .superscript: + result = .superscript(result) + case .marked: + result = .marked(result) + } + } + return result +} + +private func markdownAlternateDescription(attributes: [NSAttributedString.Key: Any]) -> String? { + if let value = attributes[markdownAlternateDescriptionAttribute] as? String, !value.isEmpty { + return value + } + return nil +} + +private func markdownImageURL(attributes: [NSAttributedString.Key: Any]) -> String? { + if let value = attributes[markdownImageURLAttribute] as? URL { + return value.absoluteString + } + if let value = attributes[markdownImageURLAttribute] as? NSURL { + return (value as URL).absoluteString + } + if let value = attributes[markdownImageURLAttribute] as? String, !value.isEmpty { + return value + } + return nil +} + +private func markdownResolveImageSource(_ value: String) -> MarkdownResolvedImageSource { + if value.hasPrefix("//") { + return .remote("https:\(value)") + } + + if value.lowercased().hasPrefix("data:") { + return markdownResolveDataImageSource(value) + } + + guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else { + return .unsupported + } + + switch scheme { + case "http", "https": + return .remote(url.absoluteString) + case "data": + return markdownResolveDataImageSource(url.absoluteString) + default: + return .unsupported + } +} + +private func markdownResolveDataImageSource(_ value: String) -> MarkdownResolvedImageSource { + guard value.lowercased().hasPrefix("data:"), + let commaIndex = value.firstIndex(of: ",") else { + return .unsupported + } + + let header = String(value[value.index(value.startIndex, offsetBy: 5) ..< commaIndex]) + let payloadStart = value.index(after: commaIndex) + let payload = String(value[payloadStart...]) + let isBase64 = header.lowercased().contains(";base64") + + let data: Data? + if isBase64 { + data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) + } else if let decodedPayload = payload.removingPercentEncoding { + data = decodedPayload.data(using: .utf8) + } else { + data = nil + } + + guard let data, + let image = UIImage(data: data), + let dimensions = markdownImagePixelDimensions(image) else { + return .unsupported + } + + return .data(data, dimensions) +} + +private func markdownImagePixelDimensions(_ image: UIImage) -> PixelDimensions? { + if let cgImage = image.cgImage { + return PixelDimensions(width: Int32(cgImage.width), height: Int32(cgImage.height)) + } + + let width = max(1, Int32(ceil(image.size.width * image.scale))) + let height = max(1, Int32(ceil(image.size.height * image.scale))) + return PixelDimensions(width: width, height: height) +} + +private func markdownImageCaption(_ title: String?) -> InstantPageCaption { + if let title, !title.isEmpty { + return InstantPageCaption(text: .plain(title), credit: .empty) + } else { + return InstantPageCaption(text: .empty, credit: .empty) + } +} + +private func markdownInlineImageDimensions(attributes: [NSAttributedString.Key: Any]) -> PixelDimensions { + guard let font = attributes[.font] as? UIFont else { + return markdownDefaultInlineImageDimensions + } + + let side = max(markdownDefaultInlineImageDimensions.width, Int32(ceil(font.lineHeight))) + return PixelDimensions(width: side, height: side) +} + +private func markdownLink(attributes: [NSAttributedString.Key: Any], documentURL: URL) -> String? { + if let value = attributes[markdownLinkAttribute] as? URL { + return markdownNormalizedLink(value, documentURL: documentURL) + } + if let value = attributes[markdownLinkAttribute] as? NSURL { + return markdownNormalizedLink(value as URL, documentURL: documentURL) + } + if let value = attributes[markdownLinkAttribute] as? String, !value.isEmpty { + if value.hasPrefix("#") { + return value + } + if let url = URL(string: value) { + return markdownNormalizedLink(url, documentURL: documentURL) + } + return value + } + return nil +} + +private func markdownNormalizedLink(_ url: URL, documentURL: URL) -> String { + if url.baseURL != nil { + let relative = url.relativeString + if relative.hasPrefix("#") { + return relative + } + } + if let fragment = url.fragment, markdownMatchesDocument(url, documentURL: documentURL) { + return "#\(fragment)" + } + return url.absoluteString +} + +private func markdownMatchesDocument(_ url: URL, documentURL: URL) -> Bool { + let normalizedUrl = markdownURLWithoutFragment(url) + let normalizedDocumentURL = markdownURLWithoutFragment(documentURL) + + if normalizedUrl.isFileURL && normalizedDocumentURL.isFileURL { + return normalizedUrl.standardizedFileURL == normalizedDocumentURL.standardizedFileURL + } else { + return normalizedUrl == normalizedDocumentURL + } +} + +private func markdownURLWithoutFragment(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return url + } + components.fragment = nil + return components.url ?? url +} + +private func markdownCompact(_ fragments: [RichText]) -> RichText { + var compacted: [RichText] = [] + for fragment in fragments { + switch fragment { + case .empty: + continue + case let .plain(text): + guard !text.isEmpty else { + continue + } + if let last = compacted.last, case let .plain(lastText) = last { + compacted[compacted.count - 1] = .plain(lastText + text) + } else { + compacted.append(fragment) + } + case let .concat(items): + let nested = markdownCompact(items) + switch nested { + case .empty: + continue + case let .plain(text): + if let last = compacted.last, case let .plain(lastText) = last { + compacted[compacted.count - 1] = .plain(lastText + text) + } else { + compacted.append(.plain(text)) + } + default: + compacted.append(nested) + } + default: + compacted.append(fragment) + } + } + if compacted.isEmpty { + return .empty + } else if compacted.count == 1 { + return compacted[0] + } else { + return .concat(compacted) + } +} + +private func markdownHasDisplayableContent(_ richText: RichText) -> Bool { + switch richText { + case .empty: + return false + case let .plain(text): + return !text.isEmpty + case let .bold(text), + let .italic(text), + let .underline(text), + let .strikethrough(text), + let .fixed(text), + let .subscript(text), + let .superscript(text), + let .marked(text), + let .anchor(text, _): + return markdownHasDisplayableContent(text) + case let .url(text, _, _), + let .email(text, _), + let .phone(text, _): + return markdownHasDisplayableContent(text) + case let .concat(items): + return items.contains(where: markdownHasDisplayableContent) + case .image: + return true + } +} + +private func markdownIsWhitespaceOnly(_ richText: RichText) -> Bool { + switch richText { + case .empty: + return true + case let .plain(text): + return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case let .bold(text), + let .italic(text), + let .underline(text), + let .strikethrough(text), + let .fixed(text), + let .subscript(text), + let .superscript(text), + let .marked(text), + let .anchor(text, _): + return markdownIsWhitespaceOnly(text) + case let .url(text, _, _), + let .email(text, _), + let .phone(text, _): + return markdownIsWhitespaceOnly(text) + case let .concat(items): + return items.allSatisfy(markdownIsWhitespaceOnly) + case .image: + return false + } +} + +private func markdownPlainText(from block: InstantPageBlock) -> String { + switch block { + case let .title(text): + return text.plainText + case let .subtitle(text): + return text.plainText + case let .authorDate(author, _): + return author.plainText + case let .header(text): + return text.plainText + case let .subheader(text): + return text.plainText + case let .paragraph(text): + return text.plainText + case let .preformatted(text): + return text.plainText + case let .footer(text): + return text.plainText + case let .blockQuote(text, caption): + return text.plainText.isEmpty ? caption.plainText : text.plainText + case let .pullQuote(text, caption): + return text.plainText.isEmpty ? caption.plainText : text.plainText + case let .kicker(text): + return text.plainText + case let .table(title, _, _, _): + return title.plainText + case let .details(title, _, _): + return title.plainText + case let .relatedArticles(title, _): + return title.plainText + default: + return "" + } +} + +private func markdownTitle(from blocks: [InstantPageBlock], file: FileMediaReference, fileURL: URL) -> String { + for block in blocks { + if case let .title(text) = block, !text.plainText.isEmpty { + return text.plainText + } + } + if let fileName = file.media.fileName, !fileName.isEmpty { + let baseName = URL(fileURLWithPath: fileName).deletingPathExtension().lastPathComponent + if !baseName.isEmpty { + return baseName + } + return fileName + } + let baseName = fileURL.deletingPathExtension().lastPathComponent + if !baseName.isEmpty { + return baseName + } + return fileURL.lastPathComponent +} + +private func markdownFirstParagraphText(from blocks: [InstantPageBlock]) -> String? { + for block in blocks { + switch block { + case let .paragraph(text): + if !text.plainText.isEmpty { + return text.plainText + } + case let .list(items, _): + for item in items { + switch item { + case let .text(text, _): + if !text.plainText.isEmpty { + return text.plainText + } + case let .blocks(blocks, _): + if let text = markdownFirstParagraphText(from: blocks) { + return text + } + default: + break + } + } + case let .details(_, blocks, _): + if let text = markdownFirstParagraphText(from: blocks) { + return text + } + default: + break + } + } + return nil +} + +private func markdownBlocksWithGeneratedAnchors(_ blocks: [InstantPageBlock]) -> [InstantPageBlock] { + var result: [InstantPageBlock] = [] + var slugCounts: [String: Int] = [:] + + for block in blocks { + if let headingText = markdownHeadingText(from: block), !headingText.isEmpty { + let baseSlug = markdownAnchorSlug(from: headingText) + if !baseSlug.isEmpty { + let count = slugCounts[baseSlug] ?? 0 + slugCounts[baseSlug] = count + 1 + + let slug: String + if count == 0 { + slug = baseSlug + } else { + slug = "\(baseSlug)-\(count)" + } + result.append(.anchor(slug)) + } + } + result.append(block) + } + + return result +} + +private func markdownHeadingText(from block: InstantPageBlock) -> String? { + switch block { + case let .title(text): + return text.plainText + case let .header(text): + return text.plainText + case let .subheader(text): + return text.plainText + default: + return nil + } +} + +private func markdownAnchorSlug(from text: String) -> String { + let normalized = text + .folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + .lowercased() + + let dashScalar = "-".unicodeScalars.first! + let separatorSet = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "-_")) + var scalars: [UnicodeScalar] = [] + var previousWasDash = false + + for scalar in normalized.unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + scalars.append(scalar) + previousWasDash = false + } else if separatorSet.contains(scalar) { + if !scalars.isEmpty && !previousWasDash { + scalars.append(dashScalar) + previousWasDash = true + } + } + } + + if scalars.last == dashScalar { + scalars.removeLast() + } + + return String(String.UnicodeScalarView(scalars)) +} + +private func markdownTrimTrailingCodeBlockNewline(_ attributedString: NSAttributedString) -> NSAttributedString { + guard attributedString.length > 0 else { + return attributedString + } + let mutable = NSMutableAttributedString(attributedString: attributedString) + let string = mutable.string + if string.hasSuffix("\r\n"), mutable.length >= 2 { + mutable.deleteCharacters(in: NSRange(location: mutable.length - 2, length: 2)) + } else if string.hasSuffix("\n") { + mutable.deleteCharacters(in: NSRange(location: mutable.length - 1, length: 1)) + } + return mutable +} + +private enum MarkdownIntentKind { + case table([TableHorizontalAlignment]) + case tableHeaderRow + case tableRow + case tableCell(Int) + case paragraph + case header(Int) + case codeBlock(String?) + case thematicBreak + case blockQuote + case unorderedList + case orderedList + case listItem(Int) + case unknown + + @available(iOS 15.0, *) + init(component: PresentationIntent.IntentType) { + switch component.kind { + case let .table(columns): + self = .table(columns.map(markdownTableColumnAlignment)) + case .tableHeaderRow: + self = .tableHeaderRow + case .tableRow(_): + self = .tableRow + case let .tableCell(column): + self = .tableCell(column) + case .paragraph: + self = .paragraph + case let .header(level): + self = .header(level) + case let .codeBlock(languageHint): + self = .codeBlock(languageHint) + case .thematicBreak: + self = .thematicBreak + case .blockQuote: + self = .blockQuote + case .unorderedList: + self = .unorderedList + case .orderedList: + self = .orderedList + case let .listItem(ordinal): + self = .listItem(ordinal) + default: + self = .unknown + } + } +} + +@available(iOS 15.0, *) +private func markdownTableColumnAlignment(_ column: PresentationIntent.TableColumn) -> TableHorizontalAlignment { + switch column.alignment { + case .left: + return .left + case .center: + return .center + case .right: + return .right + @unknown default: + return .left + } +} + +private final class MarkdownIntentNode { + let identity: Int + let kind: MarkdownIntentKind + + private(set) var children: [MarkdownIntentNode] = [] + private var childIdentities: Set = [] + private(set) var attributedText = NSMutableAttributedString(string: "") + + @available(iOS 15.0, *) + init(component: PresentationIntent.IntentType) { + self.identity = component.identity + self.kind = MarkdownIntentKind(component: component) + } + + func append(child: MarkdownIntentNode) { + if self.childIdentities.insert(child.identity).inserted { + self.children.append(child) + } + } + + func append(text: NSAttributedString) { + self.attributedText.append(text) + } +} diff --git a/submodules/BrowserUI/Sources/BrowserPdfContent.swift b/submodules/BrowserUI/Sources/BrowserPdfContent.swift index 89507435b3..0f723e51ef 100644 --- a/submodules/BrowserUI/Sources/BrowserPdfContent.swift +++ b/submodules/BrowserUI/Sources/BrowserPdfContent.swift @@ -364,7 +364,7 @@ final class BrowserPdfContent: UIView, BrowserContent, UIScrollViewDelegate, PDF let pageIndicatorSize = self.pageIndicator.update( transition: .immediate, component: AnyComponent( - Text(text: "\(self.pageNumber?.0 ?? 1) of \(self.pageNumber?.1 ?? 1)", font: Font.with(size: 15.0, weight: .regular, traits: .monospacedNumbers), color: self.presentationData.theme.list.itemPrimaryTextColor) + Text(text: self.presentationData.strings.Items_NOfM("\(self.pageNumber?.0 ?? 1)", "\(self.pageNumber?.1 ?? 1)").string, font: Font.with(size: 15.0, weight: .regular, traits: .monospacedNumbers), color: self.presentationData.theme.list.itemPrimaryTextColor) ), environment: {}, containerSize: size diff --git a/submodules/BrowserUI/Sources/BrowserScreen.swift b/submodules/BrowserUI/Sources/BrowserScreen.swift index 4349ea6aaa..c93cb4d9f7 100644 --- a/submodules/BrowserUI/Sources/BrowserScreen.swift +++ b/submodules/BrowserUI/Sources/BrowserScreen.swift @@ -29,6 +29,7 @@ private final class BrowserScreenComponent: CombinedComponent { let context: AccountContext let contentState: BrowserContentState? let presentationState: BrowserPresentationState + let toolbarMode: BrowserToolbarMode let canShare: Bool let performAction: ActionSlot let performHoldAction: (UIView, ContextGesture?, BrowserScreen.Action) -> Void @@ -38,6 +39,7 @@ private final class BrowserScreenComponent: CombinedComponent { context: AccountContext, contentState: BrowserContentState?, presentationState: BrowserPresentationState, + toolbarMode: BrowserToolbarMode, canShare: Bool, performAction: ActionSlot, performHoldAction: @escaping (UIView, ContextGesture?, BrowserScreen.Action) -> Void, @@ -46,6 +48,7 @@ private final class BrowserScreenComponent: CombinedComponent { self.context = context self.contentState = contentState self.presentationState = presentationState + self.toolbarMode = toolbarMode self.canShare = canShare self.performAction = performAction self.performHoldAction = performHoldAction @@ -62,6 +65,9 @@ private final class BrowserScreenComponent: CombinedComponent { if lhs.presentationState != rhs.presentationState { return false } + if lhs.toolbarMode != rhs.toolbarMode { + return false + } if lhs.canShare != rhs.canShare { return false } @@ -112,8 +118,7 @@ private final class BrowserScreenComponent: CombinedComponent { navigationLeftItems = [] navigationRightItems = [] } else { - let contentType = context.component.contentState?.contentType ?? .instantPage - switch contentType { + switch context.component.toolbarMode { case .webPage: navigationContent = AnyComponentWithIdentity( id: "addressBar", @@ -131,7 +136,7 @@ private final class BrowserScreenComponent: CombinedComponent { ) ) ) - case .instantPage, .document: + case .instantPage, .document, .markdown: let title = context.component.contentState?.title ?? "" navigationContent = AnyComponentWithIdentity( id: "titleBar_\(title)", @@ -190,47 +195,46 @@ private final class BrowserScreenComponent: CombinedComponent { // ) // ) // #endif - - let canGoBack = context.component.contentState?.canGoBack ?? false - let canGoForward = context.component.contentState?.canGoForward ?? false - - navigationLeftItems.append( - AnyComponentWithIdentity( - id: "back", - component: AnyComponent( - Button( - content: AnyComponent( - BundleIconComponent( - name: "Instant View/Back", - tintColor: environment.theme.chat.inputPanel.panelControlColor.withAlphaComponent(canGoBack ? 1.0 : 0.4) - ) - ), - action: { - performAction.invoke(.navigateBack) - } - ).minSize(CGSize(width: 44.0, height: 44.0)) + if context.component.toolbarMode != .markdown { + let canGoBack = context.component.contentState?.canGoBack ?? false + let canGoForward = context.component.contentState?.canGoForward ?? false + navigationLeftItems.append( + AnyComponentWithIdentity( + id: "back", + component: AnyComponent( + Button( + content: AnyComponent( + BundleIconComponent( + name: "Instant View/Back", + tintColor: environment.theme.chat.inputPanel.panelControlColor.withAlphaComponent(canGoBack ? 1.0 : 0.4) + ) + ), + action: { + performAction.invoke(.navigateBack) + } + ).minSize(CGSize(width: 44.0, height: 44.0)) + ) ) ) - ) - - navigationLeftItems.append( - AnyComponentWithIdentity( - id: "forward", - component: AnyComponent( - Button( - content: AnyComponent( - BundleIconComponent( - name: "Instant View/Forward", - tintColor: environment.theme.chat.inputPanel.panelControlColor.withAlphaComponent(canGoForward ? 1.0 : 0.4) - ) - ), - action: { - performAction.invoke(.navigateForward) - } - ).minSize(CGSize(width: 44.0, height: 44.0)) + navigationLeftItems.append( + AnyComponentWithIdentity( + id: "forward", + component: AnyComponent( + Button( + content: AnyComponent( + BundleIconComponent( + name: "Instant View/Forward", + tintColor: environment.theme.chat.inputPanel.panelControlColor.withAlphaComponent(canGoForward ? 1.0 : 0.4) + ) + ), + action: { + performAction.invoke(.navigateForward) + } + ).minSize(CGSize(width: 44.0, height: 44.0)) + ) ) ) - ) + } } navigationRightItems = [ @@ -258,25 +262,27 @@ private final class BrowserScreenComponent: CombinedComponent { ] if isTablet { - navigationRightItems.insert( - AnyComponentWithIdentity( - id: "bookmarks", - component: AnyComponent( - Button( - content: AnyComponent( - BundleIconComponent( - name: "Instant View/Bookmark", - tintColor: environment.theme.chat.inputPanel.panelControlColor - ) - ), - action: { - performAction.invoke(.openBookmarks) - } - ).minSize(CGSize(width: 44.0, height: 44.0)) - ) - ), - at: 0 - ) + if context.component.toolbarMode != .markdown { + navigationRightItems.insert( + AnyComponentWithIdentity( + id: "bookmarks", + component: AnyComponent( + Button( + content: AnyComponent( + BundleIconComponent( + name: "Instant View/Bookmark", + tintColor: environment.theme.chat.inputPanel.panelControlColor + ) + ), + action: { + performAction.invoke(.openBookmarks) + } + ).minSize(CGSize(width: 44.0, height: 44.0)) + ) + ), + at: 0 + ) + } if context.component.canShare { navigationRightItems.insert( AnyComponentWithIdentity( @@ -369,7 +375,7 @@ private final class BrowserScreenComponent: CombinedComponent { canGoForward: context.component.contentState?.canGoForward ?? false, canOpenIn: canOpenIn, canShare: context.component.canShare, - isDocument: context.component.contentState?.contentType == .document, + mode: context.component.toolbarMode, performAction: performAction, performHoldAction: performHoldAction ) @@ -517,7 +523,45 @@ public class BrowserScreen: ViewController, MinimizableController { private var presentationData: PresentationData private var presentationDataDisposable: Disposable? private var validLayout: (ContainerViewLayout, CGFloat)? - + + private var isMarkdownDocument: Bool { + guard let controller = self.controller else { + return false + } + if case .markdownDocument = controller.subject { + return true + } else { + return false + } + } + private var isMarkdownTopLevelContent: Bool { + return self.isMarkdownDocument && self.content.count <= 1 + } + + private var isMarkdownInstantPageContent: Bool { + return self.isMarkdownTopLevelContent && self.content.last is BrowserInstantPageContent + } + + private var toolbarMode: BrowserToolbarMode { + if self.isMarkdownInstantPageContent { + return .markdown + } + switch self.contentState?.contentType { + case .document: + return .document + case .webPage: + return .webPage + case .instantPage: + return .instantPage + case .none: + if self.content.last is BrowserDocumentContent || self.content.last is BrowserPdfContent { + return .document + } else { + return .instantPage + } + } + } + init(controller: BrowserScreen) { self.context = controller.context self.controller = controller @@ -535,7 +579,7 @@ public class BrowserScreen: ViewController, MinimizableController { super.init() self.pushContent(controller.subject, transition: .immediate) - if let content = self.content.last { + if let content = self.content.last, !self.isMarkdownDocument { content.addToRecentlyVisited() } @@ -562,7 +606,44 @@ public class BrowserScreen: ViewController, MinimizableController { let presentationData = self.presentationData let subject: ShareControllerSubject var isDocument = false - if let content = self.content.last { + if let controller = self.controller { + switch controller.subject { + case let .document(file, _), let .pdfDocument(file, _): + subject = .media(file.abstract, nil) + isDocument = true + case let .markdownDocument(file, _): + if self.isMarkdownTopLevelContent { + subject = .media(file.abstract, nil) + isDocument = true + } else if let content = self.content.last { + if let documentContent = content as? BrowserDocumentContent { + subject = .media(documentContent.file.abstract, nil) + isDocument = true + } else if let documentContent = content as? BrowserPdfContent { + subject = .media(documentContent.file.abstract, nil) + isDocument = true + } else { + subject = .url(url) + } + } else { + subject = .url(url) + } + default: + if let content = self.content.last { + if let documentContent = content as? BrowserDocumentContent { + subject = .media(documentContent.file.abstract, nil) + isDocument = true + } else if let documentContent = content as? BrowserPdfContent { + subject = .media(documentContent.file.abstract, nil) + isDocument = true + } else { + subject = .url(url) + } + } else { + subject = .url(url) + } + } + } else if let content = self.content.last { if let documentContent = content as? BrowserDocumentContent { subject = .media(documentContent.file.abstract, nil) isDocument = true @@ -641,7 +722,10 @@ public class BrowserScreen: ViewController, MinimizableController { var processed = false if let controller = self.controller { switch controller.subject { - case let .document(file, canShare), let .pdfDocument(file, canShare): + case let .document(file, canShare), let .pdfDocument(file, canShare), let .markdownDocument(file, canShare): + if case .markdownDocument = controller.subject, !self.isMarkdownTopLevelContent { + break + } processed = true controller.openDocument(file.media, canShare) default: @@ -855,6 +939,20 @@ public class BrowserScreen: ViewController, MinimizableController { browserContent = BrowserDocumentContent(context: self.context, presentationData: self.presentationData, file: file) case let .pdfDocument(file, _): browserContent = BrowserPdfContent(context: self.context, presentationData: self.presentationData, file: file) + case let .markdownDocument(file, _): + if let (webPage, fileURL) = markdownWebpage(context: self.context, file: file) { + browserContent = BrowserInstantPageContent( + context: self.context, + presentationData: self.presentationData, + webPage: webPage, + anchor: nil, + url: fileURL.absoluteString, + sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .otherPrivate), + preloadedResouces: nil + ) + } else { + browserContent = BrowserDocumentContent(context: self.context, presentationData: self.presentationData, file: file) + } } browserContent.pushContent = { [weak self] content, additionalContent in guard let self else { @@ -925,6 +1023,10 @@ public class BrowserScreen: ViewController, MinimizableController { } func popContent(transition: ComponentTransition) { + guard self.content.count > 1 else { + return + } + self.content.removeLast() self.requestLayout(transition: transition) @@ -1148,10 +1250,11 @@ public class BrowserScreen: ViewController, MinimizableController { } let canOpenIn = !(self.contentState?.url.hasPrefix("tonsite") ?? false) + let toolbarMode = self.toolbarMode var canShare = true if let controller = self.controller { switch controller.subject { - case let .document(_, canShareValue), let .pdfDocument(_, canShareValue): + case let .document(_, canShareValue), let .pdfDocument(_, canShareValue), let .markdownDocument(_, canShareValue): canShare = canShareValue default: break @@ -1202,7 +1305,7 @@ public class BrowserScreen: ViewController, MinimizableController { }))) } - if [.webPage, .instantPage].contains(contentState.contentType) { + if toolbarMode != .markdown && [.webPage, .instantPage].contains(contentState.contentType) { items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.WebBrowser_AddBookmark, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Fave"), color: theme.contextMenu.primaryColor) }, action: { (controller, action) in performAction.invoke(.addBookmark) action(.default) @@ -1384,7 +1487,7 @@ public class BrowserScreen: ViewController, MinimizableController { var canShare = true if let controller = self.controller { switch controller.subject { - case let .document(_, canShareValue), let .pdfDocument(_, canShareValue): + case let .document(_, canShareValue), let .pdfDocument(_, canShareValue), let .markdownDocument(_, canShareValue): canShare = canShareValue default: break @@ -1398,6 +1501,7 @@ public class BrowserScreen: ViewController, MinimizableController { context: self.context, contentState: self.contentState, presentationState: self.presentationState, + toolbarMode: self.toolbarMode, canShare: canShare, performAction: self.performAction, performHoldAction: { [weak self] view, gesture, action in @@ -1477,10 +1581,11 @@ public class BrowserScreen: ViewController, MinimizableController { case instantPage(webPage: TelegramMediaWebpage, anchor: String?, sourceLocation: InstantPageSourceLocation, preloadedResources: [Any]?) case document(file: FileMediaReference, canShare: Bool) case pdfDocument(file: FileMediaReference, canShare: Bool) + case markdownDocument(file: FileMediaReference, canShare: Bool) public var fileId: MediaId? { switch self { - case let .document(file, _), let .pdfDocument(file, _): + case let .document(file, _), let .pdfDocument(file, _), let .markdownDocument(file, _): return file.media.fileId default: return nil @@ -1506,7 +1611,10 @@ public class BrowserScreen: ViewController, MinimizableController { "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.spreadsheetml.template", - "application/vnd.openxmlformats-officedocument.presentationml.presentation" + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/markdown", + "text/x-markdown", + "text/x-web-markdown" ] public static let supportedDocumentExtensions: [String] = [ @@ -1517,7 +1625,8 @@ public class BrowserScreen: ViewController, MinimizableController { "docx", "xls", "xlsx", - "pptx" + "pptx", + "md" ] public init(context: AccountContext, subject: Subject, preferredConfiguration: WKWebViewConfiguration? = nil, openPreviousOnClose: Bool = false) { diff --git a/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift b/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift index 5406d1d459..01546e4cd9 100644 --- a/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift +++ b/submodules/BrowserUI/Sources/BrowserToolbarComponent.swift @@ -9,6 +9,13 @@ import ContextReferenceButtonComponent import GlassBackgroundComponent import EdgeEffect +enum BrowserToolbarMode: Equatable { + case webPage + case instantPage + case document + case markdown +} + final class BrowserToolbarComponent: CombinedComponent { let theme: PresentationTheme let bottomInset: CGFloat @@ -131,7 +138,7 @@ final class NavigationToolbarContentComponent: CombinedComponent { let canGoForward: Bool let canOpenIn: Bool let canShare: Bool - let isDocument: Bool + let mode: BrowserToolbarMode let performAction: ActionSlot let performHoldAction: (UIView, ContextGesture?, BrowserScreen.Action) -> Void @@ -141,7 +148,7 @@ final class NavigationToolbarContentComponent: CombinedComponent { canGoForward: Bool, canOpenIn: Bool, canShare: Bool, - isDocument: Bool, + mode: BrowserToolbarMode, performAction: ActionSlot, performHoldAction: @escaping (UIView, ContextGesture?, BrowserScreen.Action) -> Void ) { @@ -150,7 +157,7 @@ final class NavigationToolbarContentComponent: CombinedComponent { self.canGoForward = canGoForward self.canOpenIn = canOpenIn self.canShare = canShare - self.isDocument = isDocument + self.mode = mode self.performAction = performAction self.performHoldAction = performHoldAction } @@ -171,7 +178,7 @@ final class NavigationToolbarContentComponent: CombinedComponent { if lhs.canShare != rhs.canShare { return false } - if lhs.isDocument != rhs.isDocument { + if lhs.mode != rhs.mode { return false } return true @@ -217,7 +224,8 @@ final class NavigationToolbarContentComponent: CombinedComponent { transition: .easeInOut(duration: 0.2) ) - if context.component.isDocument { + switch context.component.mode { + case .document: var originX: CGFloat = sideInset let search = search.update( @@ -270,7 +278,40 @@ final class NavigationToolbarContentComponent: CombinedComponent { .position(CGPoint(x: originX, y: availableSize.height / 2.0)) ) size.width = originX + sideInset - } else { + case .markdown: + var originX: CGFloat = sideInset + + if !context.component.canShare { + context.add(share + .position(CGPoint(x: availableSize.width / 2.0, y: 10000.0)) + ) + } else { + context.add(share + .position(CGPoint(x: originX, y: availableSize.height / 2.0)) + ) + originX += spacing + } + + let quickLook = quickLook.update( + component: Button( + content: AnyComponent( + BundleIconComponent( + name: "Instant View/OpenDocument", + tintColor: textColor + ) + ), + action: { + performAction.invoke(.openIn) + } + ).minSize(buttonSize), + availableSize: buttonSize, + transition: .easeInOut(duration: 0.2) + ) + context.add(quickLook + .position(CGPoint(x: originX, y: availableSize.height / 2.0)) + ) + size.width = originX + sideInset + case .webPage, .instantPage: var originX: CGFloat = sideInset let canGoBack = context.component.canGoBack diff --git a/submodules/BrowserUI/Sources/Utils.swift b/submodules/BrowserUI/Sources/Utils.swift index 71489f05a3..fe17f25240 100644 --- a/submodules/BrowserUI/Sources/Utils.swift +++ b/submodules/BrowserUI/Sources/Utils.swift @@ -23,8 +23,6 @@ func fetchFavicon(context: AccountContext, url: String, size: CGSize) -> Signal< if let data { if let image = UIImage(data: data) { return image - } else if url.lowercased().contains(".svg"), let preparedData = prepareSvgImage(data, false), let image = renderPreparedImage(preparedData, size, .clear, UIScreenScale, false) { - return image } return nil } else { diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index e7e05ee43c..91962df4e2 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -2384,11 +2384,8 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var currentStatusIconContent: EmojiStatusComponent.Content? var currentStatusIconParticleColor: UIColor? var currentSecretIconImage: UIImage? - var currentForwardedIcon: UIImage? - var currentStoryIcon: UIImage? - var currentGiftIcon: UIImage? - var currentLocationIcon: UIImage? - var currentPollIcon: UIImage? + var currentMessageTypeIcon: UIImage? + var currentMessageTypeIconOffset: CGPoint = .zero var selectableControlSizeAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)? var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)? @@ -2559,12 +2556,26 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var contentImageSpecs: [ContentImageSpec] = [] var avatarContentImageSpec: ContentImageSpec? var forumThread: (id: Int64, title: String, iconId: Int64?, iconColor: Int32, threadPeer: EnginePeer?, isUnread: Bool)? - - var displayForwardedIcon = false - var displayStoryReplyIcon = false - var displayGiftIcon = false - var displayLocationIcon = false - var displayPollIcon = false + + enum MessageTypeIcon { + enum CallType { + case voice + case video + } + enum CallDirection { + case incoming + case outgoing + } + case call(CallType, CallDirection) + case forward + case story + case gift + case location + case poll + case todo + case game + } + var messageTypeIcon: MessageTypeIcon? var ignoreForwardedIcon = false switch contentData { @@ -2874,24 +2885,29 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { if !ignoreForwardedIcon { if case .savedMessagesChats = item.chatListLocation { - displayForwardedIcon = false } else if let forwardInfo = message.forwardInfo, !forwardInfo.flags.contains(.isImported) && !message.id.peerId.isVerificationCodes { - displayForwardedIcon = true + messageTypeIcon = .forward } else if let _ = message.attributes.first(where: { $0 is ReplyStoryAttribute }) { - displayStoryReplyIcon = true + messageTypeIcon = .story } else { for media in message.media { if let _ = media as? TelegramMediaPoll { - displayPollIcon = true + messageTypeIcon = .poll + } else if let _ = media as? TelegramMediaTodo { + messageTypeIcon = .todo + } else if let _ = media as? TelegramMediaGame { + messageTypeIcon = .game } else if let _ = media as? TelegramMediaMap { - displayLocationIcon = true + messageTypeIcon = .location } else if let action = media as? TelegramMediaAction { switch action.action { + case let .phoneCall(_, _, _, isVideo): + messageTypeIcon = .call(isVideo ? .video : .voice, message.flags.contains(.Incoming) ? .incoming : .outgoing) case .giftPremium, .giftStars, .starGift, .starGiftUnique: - displayGiftIcon = true + messageTypeIcon = .gift case let .giftCode(_, _, _, boostPeerId, _, _, _, _, _, _, _): if boostPeerId == nil { - displayGiftIcon = true + messageTypeIcon = .gift } default: break @@ -3048,64 +3064,50 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { attributedText = textString } - if displayForwardedIcon { - currentForwardedIcon = PresentationResourcesChatList.forwardedIcon(item.presentationData.theme) - } - - if displayStoryReplyIcon { - currentStoryIcon = PresentationResourcesChatList.storyReplyIcon(item.presentationData.theme) - } - - if displayGiftIcon { - currentGiftIcon = PresentationResourcesChatList.giftIcon(item.presentationData.theme) - } - - if displayLocationIcon { - currentLocationIcon = PresentationResourcesChatList.locationIcon(item.presentationData.theme) - } - - if displayPollIcon { - currentPollIcon = PresentationResourcesChatList.pollIcon(item.presentationData.theme) - } - - if let currentForwardedIcon { - textLeftCutout += currentForwardedIcon.size.width - if !contentImageSpecs.isEmpty { - textLeftCutout += forwardedIconSpacing - } else { - textLeftCutout += contentImageTrailingSpace + switch messageTypeIcon { + case let .call(type, direction): + switch type { + case .voice: + switch direction { + case .incoming: + currentMessageTypeIcon = PresentationResourcesChatList.callIncomingIcon(item.presentationData.theme) + case .outgoing: + currentMessageTypeIcon = PresentationResourcesChatList.callOutgoingIcon(item.presentationData.theme) + } + case .video: + switch direction { + case .incoming: + currentMessageTypeIcon = PresentationResourcesChatList.callVideoIncomingIcon(item.presentationData.theme) + case .outgoing: + currentMessageTypeIcon = PresentationResourcesChatList.callVideoOutgoingIcon(item.presentationData.theme) + } } + case .forward: + currentMessageTypeIcon = PresentationResourcesChatList.forwardedIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = 3.0 + case .story: + currentMessageTypeIcon = PresentationResourcesChatList.storyReplyIcon(item.presentationData.theme) + case .gift: + currentMessageTypeIcon = PresentationResourcesChatList.giftIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -2.0 - UIScreenPixel + case .location: + currentMessageTypeIcon = PresentationResourcesChatList.locationIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -1.0 - UIScreenPixel + case .poll: + currentMessageTypeIcon = PresentationResourcesChatList.pollIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -1.0 + case .todo: + currentMessageTypeIcon = PresentationResourcesChatList.todoIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -1.0 + case .game: + currentMessageTypeIcon = PresentationResourcesChatList.gameIcon(item.presentationData.theme) + currentMessageTypeIconOffset.y = -1.0 + default: + break } - if let currentStoryIcon { - textLeftCutout += currentStoryIcon.size.width - if !contentImageSpecs.isEmpty { - textLeftCutout += forwardedIconSpacing - } else { - textLeftCutout += contentImageTrailingSpace - } - } - - if let currentGiftIcon { - textLeftCutout += currentGiftIcon.size.width - if !contentImageSpecs.isEmpty { - textLeftCutout += forwardedIconSpacing - } else { - textLeftCutout += contentImageTrailingSpace - } - } - - if let currentLocationIcon { - textLeftCutout += currentLocationIcon.size.width - if !contentImageSpecs.isEmpty { - textLeftCutout += forwardedIconSpacing - } else { - textLeftCutout += contentImageTrailingSpace - } - } - - if let currentPollIcon { - textLeftCutout += currentPollIcon.size.width + if let currentMessageTypeIcon { + textLeftCutout += currentMessageTypeIcon.size.width if !contentImageSpecs.isEmpty { textLeftCutout += forwardedIconSpacing } else { @@ -4765,31 +4767,16 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var mediaPreviewOffset = textNodeFrame.origin.offsetBy(dx: 1.0, dy: 1.0 + floor((measureLayout.size.height - contentImageSize.height) / 2.0)) - var messageTypeIcon: UIImage? - var messageTypeIconOffset = mediaPreviewOffset - if let currentForwardedIcon { - messageTypeIcon = currentForwardedIcon - messageTypeIconOffset.y += 3.0 - } else if let currentStoryIcon { - messageTypeIcon = currentStoryIcon - } else if let currentGiftIcon { - messageTypeIcon = currentGiftIcon - messageTypeIconOffset.y -= 2.0 - UIScreenPixel - } else if let currentLocationIcon { - messageTypeIcon = currentLocationIcon - messageTypeIconOffset.y -= 2.0 - UIScreenPixel - } else if let currentPollIcon { - messageTypeIcon = currentPollIcon - messageTypeIconOffset.y -= 2.0 - UIScreenPixel - } + let messageTypeIconImage = currentMessageTypeIcon + let messageTypeIconOffset = CGPoint(x: mediaPreviewOffset.x + currentMessageTypeIconOffset.x, y: mediaPreviewOffset.y + currentMessageTypeIconOffset.y) - if let messageTypeIcon { - strongSelf.forwardedIconNode.image = messageTypeIcon + if let messageTypeIconImage { + strongSelf.forwardedIconNode.image = messageTypeIconImage if strongSelf.forwardedIconNode.supernode == nil { strongSelf.mainContentContainerNode.addSubnode(strongSelf.forwardedIconNode) } - transition.updateFrame(node: strongSelf.forwardedIconNode, frame: CGRect(origin: messageTypeIconOffset, size: messageTypeIcon.size)) - mediaPreviewOffset.x += messageTypeIcon.size.width + forwardedIconSpacing + transition.updateFrame(node: strongSelf.forwardedIconNode, frame: CGRect(origin: messageTypeIconOffset, size: messageTypeIconImage.size)) + mediaPreviewOffset.x += messageTypeIconImage.size.width + forwardedIconSpacing } else if strongSelf.forwardedIconNode.supernode != nil { strongSelf.forwardedIconNode.removeFromSupernode() } diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 824887b5d4..05fe3b01f8 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -311,7 +311,7 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: case _ as TelegramMediaContact: messageText = strings.Message_Contact case let game as TelegramMediaGame: - messageText = "🎮 \(game.title)" + messageText = game.title case let invoice as TelegramMediaInvoice: messageText = invoice.title case let action as TelegramMediaAction: @@ -438,15 +438,11 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: messageText = content.displayUrl } case let todo as TelegramMediaTodo: - let pollPrefix = "☑️ " - let entityOffset = (pollPrefix as NSString).length - messageText = "\(pollPrefix)\(todo.text)" + messageText = todo.text + customEmojiRanges = [] for entity in todo.textEntities { if case let .CustomEmoji(_, fileId) = entity.type { - if customEmojiRanges == nil { - customEmojiRanges = [] - } - let range = NSRange(location: entityOffset + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) + let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: message.associatedMedia[EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile) customEmojiRanges?.append((range, attribute)) } diff --git a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift index 42d00f8d85..5ae4f9ddcc 100644 --- a/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift +++ b/submodules/Components/ReactionListContextMenuContent/Sources/ReactionListContextMenuContent.swift @@ -383,8 +383,15 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent private var iconFrame: CGRect? private var file: TelegramMediaFile? private var fileDisposable: Disposable? + private var longTapRecognizer: UILongPressGestureRecognizer? + private var skipNextTapAction = false let action: () -> Void + var longTapAction: (() -> Void)? { + didSet { + self.longTapRecognizer?.isEnabled = self.longTapAction != nil + } + } private var item: EngineMessageReactionListContext.Item? @@ -422,6 +429,12 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.view.addSubview(self.readIconView) self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) + + let longTapRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longTapGesture(_:))) + longTapRecognizer.isEnabled = false + longTapRecognizer.cancelsTouchesInView = true + self.view.addGestureRecognizer(longTapRecognizer) + self.longTapRecognizer = longTapRecognizer } deinit { @@ -429,9 +442,30 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent } @objc private func pressed() { + if self.skipNextTapAction { + self.skipNextTapAction = false + return + } self.action() } + @objc private func longTapGesture(_ recognizer: UILongPressGestureRecognizer) { + switch recognizer.state { + case .began: + guard let item = self.item, item.reaction != nil else { + return + } + self.skipNextTapAction = true + self.longTapAction?() + case .cancelled, .ended, .failed: + DispatchQueue.main.async { [weak self] in + self?.skipNextTapAction = false + } + default: + break + } + } + private func updateReactionLayer() { guard let file = self.file else { return @@ -771,6 +805,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent private let requestUpdate: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void private let requestUpdateApparentHeight: (ReactionsTabNode, ContainedViewLayoutTransition) -> Void private let openPeer: (EnginePeer, Bool) -> Void + private let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? private var hasMore: Bool = false @@ -804,7 +839,8 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent readStats: MessageReadStats?, requestUpdate: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ReactionsTabNode, ContainedViewLayoutTransition) -> Void, - openPeer: @escaping (EnginePeer, Bool) -> Void + openPeer: @escaping (EnginePeer, Bool) -> Void, + deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? ) { self.context = context self.displayReadTimestamps = displayReadTimestamps @@ -816,6 +852,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.requestUpdate = requestUpdate self.requestUpdateApparentHeight = requestUpdateApparentHeight self.openPeer = openPeer + self.deleteReaction = deleteReaction self.listContext = context.engine.messages.messageReactionList(message: message, readStats: readStats, reaction: reaction) self.state = ItemsState(listState: EngineMessageReactionListContext.State(message: message, readStats: readStats, reaction: reaction), readStats: readStats) @@ -928,6 +965,14 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent } itemNode.update(size: itemFrame.size, presentationData: presentationData, item: item, isLast: self.state.item(at: index + 1) == nil, syncronousLoad: syncronousLoad) + if let deleteReaction = self.deleteReaction, let reaction = item.reaction { + let peer = item.peer + itemNode.longTapAction = { + deleteReaction(peer, reaction) + } + } else { + itemNode.longTapAction = nil + } itemNode.frame = itemFrame } else if index < self.state.totalCount { validPlaceholderIds.insert(index) @@ -1079,6 +1124,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent private let reactions: [(MessageReaction.Reaction?, Int)] private let requestUpdate: (ContainedViewLayoutTransition) -> Void private let requestUpdateApparentHeight: (ContainedViewLayoutTransition) -> Void + private let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? private var presentationData: PresentationData @@ -1111,7 +1157,8 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void, back: (() -> Void)?, - openPeer: @escaping (EnginePeer, Bool) -> Void + openPeer: @escaping (EnginePeer, Bool) -> Void, + deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? ) { self.context = context self.displayReadTimestamps = displayReadTimestamps @@ -1126,6 +1173,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.requestUpdate = requestUpdate self.requestUpdateApparentHeight = requestUpdateApparentHeight + self.deleteReaction = deleteReaction if let back = back { self.backButtonNode = BackButtonNode() @@ -1332,7 +1380,8 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent strongSelf.requestUpdateApparentHeight(transition) } }, - openPeer: self.openPeer + openPeer: self.openPeer, + deleteReaction: self.deleteReaction ) self.addSubnode(tabNode) self.visibleTabNodes[index] = tabNode @@ -1426,6 +1475,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent let readStats: MessageReadStats? let back: (() -> Void)? let openPeer: (EnginePeer, Bool) -> Void + let deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? public init( context: AccountContext, @@ -1437,7 +1487,8 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent reaction: MessageReaction.Reaction?, readStats: MessageReadStats?, back: (() -> Void)?, - openPeer: @escaping (EnginePeer, Bool) -> Void + openPeer: @escaping (EnginePeer, Bool) -> Void, + deleteReaction: ((EnginePeer, MessageReaction.Reaction) -> Void)? = nil ) { self.context = context self.displayReadTimestamps = displayReadTimestamps @@ -1449,6 +1500,7 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent self.readStats = readStats self.back = back self.openPeer = openPeer + self.deleteReaction = deleteReaction } public func node( @@ -1467,7 +1519,8 @@ public final class ReactionListContextMenuContent: ContextControllerItemsContent requestUpdate: requestUpdate, requestUpdateApparentHeight: requestUpdateApparentHeight, back: self.back, - openPeer: self.openPeer + openPeer: self.openPeer, + deleteReaction: self.deleteReaction ) } } diff --git a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift index eda1e61c23..3bd4728b38 100644 --- a/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift +++ b/submodules/Components/ResizableSheetComponent/Sources/ResizableSheetComponent.swift @@ -13,7 +13,7 @@ public final class ResizableSheetComponentEnvironment: Equatable { public let bounds: CGRect public let isInteractive: Bool } - + public let theme: PresentationTheme public let statusBarHeight: CGFloat public let safeInsets: UIEdgeInsets @@ -26,7 +26,7 @@ public final class ResizableSheetComponentEnvironment: Equatable { public let regularMetricsSize: CGSize? public let dismiss: (Bool) -> Void public let boundsUpdated: ActionSlot - + public init( theme: PresentationTheme, statusBarHeight: CGFloat, @@ -54,7 +54,7 @@ public final class ResizableSheetComponentEnvironment: Equatable { self.dismiss = dismiss self.boundsUpdated = boundsUpdated } - + public static func ==(lhs: ResizableSheetComponentEnvironment, rhs: ResizableSheetComponentEnvironment) -> Bool { if lhs.theme != rhs.theme { return false @@ -92,19 +92,24 @@ public final class ResizableSheetComponentEnvironment: Equatable { public final class ResizableSheetComponent: Component { public typealias EnvironmentType = (ChildEnvironmentType, ResizableSheetComponentEnvironment) - + public class ExternalState { public fileprivate(set) var contentHeight: CGFloat - + fileprivate var trackedScrollViewUpdated: ((UIScrollView?) -> Void)? + public init() { self.contentHeight = 0.0 } + + public func setTrackedScrollView(_ scrollView: UIScrollView?) { + self.trackedScrollViewUpdated?(scrollView) + } } - + public enum BackgroundColor: Equatable { case color(UIColor) } - + public let content: AnyComponent public let titleItem: AnyComponent? public let leftItem: AnyComponent? @@ -113,9 +118,10 @@ public final class ResizableSheetComponent? public let backgroundColor: BackgroundColor public let isFullscreen: Bool + public let defaultHeight: CGFloat? public let externalState: ExternalState? public let animateOut: ActionSlot> - + public init( content: AnyComponent, titleItem: AnyComponent? = nil, @@ -125,6 +131,7 @@ public final class ResizableSheetComponent? = nil, backgroundColor: BackgroundColor, isFullscreen: Bool = false, + defaultHeight: CGFloat? = nil, externalState: ExternalState? = nil, animateOut: ActionSlot>, ) { @@ -136,10 +143,11 @@ public final class ResizableSheetComponent Bool { if lhs.content != rhs.content { return false @@ -165,12 +173,15 @@ public final class ResizableSheetComponent UIView? { return super.hitTest(point, with: event) } } - + public final class View: UIView, UIScrollViewDelegate, ComponentTaggedView, UIGestureRecognizerDelegate { public final class Tag { public init() { } } - + public func matches(tag: Any) -> Bool { if let _ = tag as? Tag { return true } return false } - + private let dimView: UIView public let containerView: UIView private let backgroundLayer: SimpleLayer @@ -218,54 +229,57 @@ public final class ResizableSheetComponent - + private var titleItemView: ComponentView? private var leftItemView: ComponentView? private var rightItemView: ComponentView? private var bottomItemView: ComponentView? - + private let backgroundHandleView: UIImageView - + private var ignoreScrolling: Bool = false private var isDismissingInteractively: Bool = false private var dismissTranslation: CGFloat = 0.0 private var dismissStartTranslation: CGFloat? private var dismissPanGesture: UIPanGestureRecognizer? - + private var component: ResizableSheetComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private var environment: ResizableSheetComponentEnvironment? private var itemLayout: ItemLayout? - + private var registeredExternalState: ExternalState? + private weak var trackedScrollView: UIScrollView? + private var trackedScrollViewWasAtTopOnGestureBegan = false + override init(frame: CGRect) { self.dimView = UIView() self.containerView = UIView() - + self.containerView.clipsToBounds = true self.containerView.layer.cornerRadius = 40.0 self.containerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - + self.backgroundLayer = SimpleLayer() self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] self.backgroundLayer.cornerRadius = 40.0 - + self.backgroundHandleView = UIImageView() - + self.navigationBarContainer = SparseContainerView() self.bottomContainer = SparseContainerView() - + self.scrollView = ScrollView() - + self.scrollContentClippingView = SparseContainerView() self.scrollContentClippingView.clipsToBounds = true - + self.scrollContentView = UIView() - + self.topEdgeEffectView = EdgeEffectView() self.topEdgeEffectView.clipsToBounds = true self.topEdgeEffectView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] @@ -275,15 +289,16 @@ public final class ResizableSheetComponent UIView? { if !self.bounds.contains(point) { return nil @@ -331,7 +351,7 @@ public final class ResizableSheetComponent Bool { if gestureRecognizer === self.dismissPanGesture { let pan = gestureRecognizer as! UIPanGestureRecognizer @@ -352,22 +372,25 @@ public final class ResizableSheetComponent Bool { if gestureRecognizer === self.dismissPanGesture { if otherGestureRecognizer === self.scrollView.panGestureRecognizer { return true } + if otherGestureRecognizer === self.trackedScrollView?.panGestureRecognizer { + return true + } } return false } - + @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.dismissAnimated() } } - + public func dismissAnimated() { guard let environment = self.environment else { return @@ -375,16 +398,16 @@ public final class ResizableSheetComponent threshold || velocityY > 1000.0 - + self.isDismissingInteractively = false self.scrollView.isScrollEnabled = !component.isFullscreen - + if shouldDismiss { let animateOffset = self.bounds.height - self.backgroundLayer.frame.minY let initialVelocity = animateOffset > 0.0 ? max(0.0, velocityY) / animateOffset : 0.0 @@ -448,73 +471,170 @@ public final class ResizableSheetComponent CGFloat { + return -scrollView.contentInset.top + } + + private func isSheetFullyExpanded(itemLayout: ItemLayout) -> Bool { + return itemLayout.topInset <= 0.5 || self.scrollView.contentOffset.y >= itemLayout.topInset - 0.5 + } + + private func pinTrackedScrollViewToTop(_ scrollView: UIScrollView) { + let topOffset = self.trackedScrollViewTopOffset(scrollView) + if abs(scrollView.contentOffset.y - topOffset) > 0.5 { + scrollView.contentOffset = CGPoint(x: scrollView.contentOffset.x, y: topOffset) + } + } + + private func updateTrackedScrollViewLock() { + guard let component = self.component, let itemLayout = self.itemLayout, let trackedScrollView = self.trackedScrollView else { + return + } + trackedScrollView.isScrollEnabled = true + if component.isFullscreen { + return + } + if !self.isSheetFullyExpanded(itemLayout: itemLayout) || self.isDismissingInteractively { + self.pinTrackedScrollViewToTop(trackedScrollView) + } + } + + private func setTrackedScrollView(_ scrollView: UIScrollView?) { + if self.trackedScrollView === scrollView { + self.updateTrackedScrollViewLock() + return + } + + if let trackedScrollView = self.trackedScrollView { + trackedScrollView.panGestureRecognizer.removeTarget(self, action: #selector(self.trackedScrollViewPanGesture(_:))) + trackedScrollView.isScrollEnabled = true + } + + self.trackedScrollView = scrollView + + if let scrollView = scrollView { + scrollView.panGestureRecognizer.addTarget(self, action: #selector(self.trackedScrollViewPanGesture(_:))) + } + + self.trackedScrollViewWasAtTopOnGestureBegan = false + self.updateTrackedScrollViewLock() + } + + @objc private func trackedScrollViewPanGesture(_ recognizer: UIPanGestureRecognizer) { + guard let component = self.component, let itemLayout = self.itemLayout, let trackedScrollView = recognizer.view as? UIScrollView else { + return + } + guard !component.isFullscreen, itemLayout.topInset > 0.5 else { + return + } + + let topOffset = self.trackedScrollViewTopOffset(trackedScrollView) + let isAtTop = trackedScrollView.contentOffset.y <= topOffset + 8.0 + + switch recognizer.state { + case .began, .changed: + if recognizer.state == .began { + self.trackedScrollViewWasAtTopOnGestureBegan = isAtTop + } + let translation = recognizer.translation(in: trackedScrollView) + let currentSheetOffset = min(max(0.0, self.scrollView.contentOffset.y), itemLayout.topInset) + let shouldExpandSheet = self.trackedScrollViewWasAtTopOnGestureBegan && currentSheetOffset < itemLayout.topInset - 0.5 + + if translation.y < 0.0 && shouldExpandSheet { + let consumedOffset = min(itemLayout.topInset - currentSheetOffset, -translation.y) + if consumedOffset > 0.0 { + self.scrollView.contentOffset = CGPoint(x: self.scrollView.contentOffset.x, y: currentSheetOffset + consumedOffset) + self.pinTrackedScrollViewToTop(trackedScrollView) + recognizer.setTranslation(.zero, in: trackedScrollView) + } + } else if translation.y > 0.0 && isAtTop && currentSheetOffset > 0.5 { + let consumedOffset = min(currentSheetOffset, translation.y) + if consumedOffset > 0.0 { + self.scrollView.contentOffset = CGPoint(x: self.scrollView.contentOffset.x, y: currentSheetOffset - consumedOffset) + self.pinTrackedScrollViewToTop(trackedScrollView) + recognizer.setTranslation(.zero, in: trackedScrollView) + } + } else if self.isDismissingInteractively || (translation.y > 0.0 && isAtTop && currentSheetOffset <= 0.5) { + self.pinTrackedScrollViewToTop(trackedScrollView) + } + case .ended, .cancelled, .failed: + self.trackedScrollViewWasAtTopOnGestureBegan = false + if !self.isSheetFullyExpanded(itemLayout: itemLayout) || self.isDismissingInteractively { + self.pinTrackedScrollViewToTop(trackedScrollView) + } + default: + break + } + } + private func updateScrolling(transition: ComponentTransition) { - guard let itemLayout = self.itemLayout, let component = self.component else { + guard let itemLayout = self.itemLayout, let component = self.component, let environment = self.environment else { return } var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset topOffset = max(0.0, topOffset) transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) - + transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) - + var topOffsetFraction = self.scrollView.bounds.minY / 100.0 topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) - - if component.isFullscreen { + + if component.isFullscreen || environment.inputHeight > 0.0 { topOffsetFraction = 1.0 } - #if DEBUG// && false - if "".isEmpty { - topOffsetFraction = 1.0 - } - #endif - +// #if DEBUG// && false +// if "".isEmpty { +// topOffsetFraction = 1.0 +// } +// #endif + let minScale: CGFloat = itemLayout.isTablet ? 1.0 : (itemLayout.containerSize.width - 6.0 * 2.0) / itemLayout.containerSize.width let minScaledTranslation: CGFloat = itemLayout.isTablet ? 0.0 : (itemLayout.containerSize.height - itemLayout.containerSize.height * minScale) * 0.5 - 6.0 let minScaledCornerRadius: CGFloat = itemLayout.containerCornerRadius - + let scale = minScale * (1.0 - topOffsetFraction) + 1.0 * topOffsetFraction let scaledTranslation = minScaledTranslation * (1.0 - topOffsetFraction) let scaledCornerRadius = minScaledCornerRadius * (1.0 - topOffsetFraction) + itemLayout.containerCornerRadius * topOffsetFraction - + var containerTransform = CATransform3DIdentity containerTransform = CATransform3DTranslate(containerTransform, 0.0, scaledTranslation, 0.0) containerTransform = CATransform3DScale(containerTransform, scale, scale, scale) containerTransform = CATransform3DTranslate(containerTransform, 0.0, self.dismissTranslation, 0.0) transition.setTransform(view: self.containerView, transform: containerTransform) transition.setCornerRadius(layer: self.containerView.layer, cornerRadius: scaledCornerRadius) - + if component.isFullscreen { transition.setBounds(view: self.scrollView, bounds: CGRect(origin: .zero, size: self.scrollView.bounds.size)) self.scrollView.isScrollEnabled = false } else { self.scrollView.isScrollEnabled = !self.isDismissingInteractively } - + var bounds = self.scrollView.bounds bounds.size.width = itemLayout.fillingSize self.environment?.boundsUpdated.invoke(ResizableSheetComponentEnvironment.BoundsUpdate(bounds: bounds, isInteractive: self.scrollView.isTracking)) + self.updateTrackedScrollViewLock() } - + private var didPlayAppearanceAnimation = false func animateIn() { self.didPlayAppearanceAnimation = true - + self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY self.containerView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } - + func animateOut(initialVelocity: CGFloat? = nil, completion: @escaping () -> Void) { let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY - + self.dimView.layer.animateAlpha(from: self.dimView.alpha, to: 0.0, duration: 0.3, removeOnCompletion: false) if let initialVelocity = initialVelocity { let transition = ContainedViewLayoutTransition.animated(duration: 0.35, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - + transition.updatePosition(layer: self.containerView.layer, position: CGPoint(x: self.containerView.layer.position.x, y: self.containerView.layer.position.y + animateOffset), completion: { _ in completion() }) @@ -525,13 +645,13 @@ public final class ResizableSheetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } - + let sheetEnvironment = environment[ResizableSheetComponentEnvironment.self].value component.animateOut.connect { [weak self] completion in guard let self else { @@ -541,9 +661,9 @@ public final class ResizableSheetComponent 0.0 { + initialContentHeight = contentHeight + } else if let defaultHeight = component.defaultHeight { + initialContentHeight = min(contentHeight, max(0.0, defaultHeight)) + } else { + initialContentHeight = contentHeight + } + let edgeEffectHeight: CGFloat = 80.0 let edgeEffectFrame = CGRect(origin: CGPoint(x: rawSideInset, y: 0.0), size: CGSize(width: fillingSize, height: edgeEffectHeight)) transition.setFrame(view: self.topEdgeEffectView, frame: edgeEffectFrame) @@ -615,7 +754,7 @@ public final class ResizableSheetComponent if let current = self.titleItemView { @@ -624,12 +763,12 @@ public final class ResizableSheetComponent() self.titleItemView = titleItemView } - + let titleItemSize = titleItemView.update( transition: transition, component: titleItem, environment: {}, - containerSize: CGSize(width: containerSize.width - 66.0 * 2.0, height: 66.0) + containerSize: CGSize(width: containerSize.width - 72.0 * 2.0, height: 66.0) ) let titleItemFrame = CGRect(origin: CGPoint(x: rawSideInset + floorToScreenPixels((containerSize.width - titleItemSize.width)) / 2.0, y: floorToScreenPixels(38.0 - titleItemSize.height * 0.5)), size: titleItemSize) if let view = titleItemView.view { @@ -642,7 +781,7 @@ public final class ResizableSheetComponent @@ -653,7 +792,7 @@ public final class ResizableSheetComponent() self.leftItemView = leftItemView } - + let leftItemSize = leftItemView.update( transition: leftItemTransition, component: leftItem, @@ -664,7 +803,7 @@ public final class ResizableSheetComponent @@ -694,7 +833,7 @@ public final class ResizableSheetComponent() self.rightItemView = rightItemView } - + let rightItemSize = rightItemView.update( transition: rightItemTransition, component: rightItem, @@ -705,7 +844,7 @@ public final class ResizableSheetComponent 0.0 { bottomInsets.left = 16.0 bottomInsets.right = 16.0 bottomInsets.bottom = sheetEnvironment.inputHeight + 8.0 } - + + var bottomEdgeEffectHeight = edgeEffectHeight if let bottomItem = component.bottomItem { var bottomItemTransition = transition let bottomItemView: ComponentView @@ -742,7 +882,7 @@ public final class ResizableSheetComponent() self.bottomItemView = bottomItemView } - + let bottomItemSize = bottomItemView.update( transition: bottomItemTransition, component: bottomItem, @@ -753,7 +893,7 @@ public final class ResizableSheetComponent View { return View(frame: CGRect()) } - + public 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/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index 6959fa0430..9c6b20390a 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -602,6 +602,7 @@ public enum ContextControllerTip: Equatable { case starsReactions(topCount: Int) case videoProcessing case collageReordering + case deleteReaction public static func ==(lhs: ContextControllerTip, rhs: ContextControllerTip) -> Bool { switch lhs { @@ -611,6 +612,12 @@ public enum ContextControllerTip: Equatable { } else { return false } + case .deleteReaction: + if case .deleteReaction = rhs { + return true + } else { + return false + } case .quoteSelection: if case .quoteSelection = rhs { return true diff --git a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift index 71505c13ee..debdbc8044 100644 --- a/submodules/InstantPageUI/Sources/InstantPageContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageContentNode.swift @@ -9,7 +9,7 @@ import TelegramUIPreferences import AccountContext import ContextUI -public final class InstantPageContentNode : ASDisplayNode { +public final class InstantPageContentNode : ASDisplayNode, InstantPageExternalMediaDimensionsNode { private let context: AccountContext private let strings: PresentationStrings private let nameDisplayOrder: PresentationPersonNameOrder @@ -29,13 +29,20 @@ public final class InstantPageContentNode : ASDisplayNode { var distanceThresholdGroupCount: [Int: Int] = [:] var visibleTiles: [Int: InstantPageTileNode] = [:] - var visibleItemsWithNodes: [Int: InstantPageNode] = [:] + public var visibleItemsWithNodes: [Int: InstantPageNode] = [:] var currentWebEmbedHeights: [Int : CGFloat] = [:] - var currentExpandedDetails: [Int : Bool]? + public var currentExpandedDetails: [Int : Bool]? var currentDetailsItems: [InstantPageDetailsItem] = [] var requestLayoutUpdate: ((Bool) -> Void)? + public var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? { + didSet { + for (_, itemNode) in self.visibleItemsWithNodes { + self.applyExternalMediaDimensionsUpdater(to: itemNode) + } + } + } var currentLayout: InstantPageLayout let contentSize: CGSize @@ -223,6 +230,7 @@ public final class InstantPageContentNode : ASDisplayNode { topNode = newNode self.visibleItemsWithNodes[itemIndex] = newNode itemNode = newNode + self.applyExternalMediaDimensionsUpdater(to: newNode) if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.requestLayoutUpdate = { [weak self] animated in @@ -303,6 +311,16 @@ public final class InstantPageContentNode : ASDisplayNode { } } + private func applyExternalMediaDimensionsUpdater(to itemNode: InstantPageNode) { + if let itemNode = itemNode as? InstantPageImageNode { + itemNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } else if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.contentNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } else if let itemNode = itemNode as? InstantPageSlideshowNode { + itemNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } + } + private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) { // let currentHeight = self.currentWebEmbedHeights[index] // if height != currentHeight { diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index 2f0445f0b7..241cd9e74e 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -65,6 +65,8 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { var currentWebEmbedHeights: [Int : CGFloat] = [:] var currentExpandedDetails: [Int : Bool]? var currentDetailsItems: [InstantPageDetailsItem] = [] + private var resolvedExternalMediaDimensions: [MediaId: PixelDimensions] = [:] + private var pendingResolvedExternalMediaDimensions = Set() var currentAccessibilityAreas: [AccessibilityAreaNode] = [] @@ -79,6 +81,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { private let loadProgressDisposable = MetaDisposable() private let updateLayoutDisposable = MetaDisposable() + private let updateExternalMediaDimensionsDisposable = MetaDisposable() private var themeReferenceDate: Date? @@ -224,6 +227,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { self.resolveUrlDisposable.dispose() self.loadWebpageDisposable.dispose() self.loadProgressDisposable.dispose() + self.updateExternalMediaDimensionsDisposable.dispose() } func update(settings: InstantPagePresentationSettings, themeSettings: PresentationThemeSettings?, strings: PresentationStrings) { @@ -362,6 +366,8 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { } else { self.webPage = nil } + self.resolvedExternalMediaDimensions.removeAll() + self.pendingResolvedExternalMediaDimensions.removeAll() if let anchor = anchor { self.initialAnchor = anchor.removingPercentEncoding } else if let state = state { @@ -465,17 +471,12 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { } private func updateLayout() { - guard let containerLayout = self.containerLayout, let (webPage, instantPage) = self.webPage, let theme = self.theme else { + guard let containerLayout = self.containerLayout, let (webPage, instantPage) = self.resolvedWebPage(), let theme = self.theme else { return } let currentLayout = instantPageLayoutForWebPage(webPage, instantPage: instantPage, userLocation: self.sourceLocation.userLocation, boundingWidth: containerLayout.size.width, safeInset: containerLayout.safeInsets.left, strings: self.strings, theme: theme, dateTimeFormat: self.dateTimeFormat, webEmbedHeights: self.currentWebEmbedHeights) - for (_, tileNode) in self.visibleTiles { - tileNode.removeFromSupernode() - } - self.visibleTiles.removeAll() - let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width) var currentDetailsItems: [InstantPageDetailsItem] = [] @@ -665,6 +666,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { topNode = newNode self.visibleItemsWithNodes[itemIndex] = newNode itemNode = newNode + self.configureExternalMediaDimensionsUpdates(for: newNode) if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.requestLayoutUpdate = { [weak self] animated in @@ -688,6 +690,10 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { } } + if let itemNode = itemNode { + self.configureExternalMediaDimensionsUpdates(for: itemNode) + } + if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.updateVisibleItems(visibleBounds: visibleBounds.offsetBy(dx: -itemNode.frame.minX, dy: -itemNode.frame.minY), animated: animated) } @@ -718,8 +724,11 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { topNode = tileNode self.visibleTiles[tileIndex] = tileNode } else { - if visibleTiles[tileIndex]!.frame != tileFrame { - transition.updateFrame(node: self.visibleTiles[tileIndex]!, frame: tileFrame) + if let tileNode = self.visibleTiles[tileIndex] { + tileNode.update(tile: tile, backgroundColor: theme.pageBackgroundColor) + if tileNode.frame != tileFrame { + transition.updateFrame(node: tileNode, frame: tileFrame) + } } } } @@ -1020,6 +1029,388 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { return nil } + private func configureExternalMediaDimensionsUpdates(for itemNode: InstantPageNode) { + let update: (MediaId, PixelDimensions) -> Void = { [weak self] mediaId, dimensions in + self?.updateExternalMediaDimensions(mediaId, dimensions) + } + if let itemNode = itemNode as? InstantPageExternalMediaDimensionsNode { + itemNode.updateExternalMediaDimensions = update + } + if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.contentNode.updateExternalMediaDimensions = update + } + } + + private func updateExternalMediaDimensions(_ mediaId: MediaId, _ dimensions: PixelDimensions) { + if self.resolvedExternalMediaDimensions[mediaId] == dimensions { + return + } + self.resolvedExternalMediaDimensions[mediaId] = dimensions + self.pendingResolvedExternalMediaDimensions.insert(mediaId) + + let signal: Signal = (.complete() |> delay(0.08, queue: Queue.mainQueue())) + self.updateExternalMediaDimensionsDisposable.set(signal.start(completed: { [weak self] in + self?.relayoutForResolvedExternalMediaDimensions() + })) + } + + private func relayoutForResolvedExternalMediaDimensions() { + guard !self.pendingResolvedExternalMediaDimensions.isEmpty else { + return + } + + let mediaIds = Array(self.pendingResolvedExternalMediaDimensions) + self.pendingResolvedExternalMediaDimensions.removeAll() + + let detailsStateMaps = self.captureExpandedDetailsStateMaps() + let viewportTop = self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentInset.top + var oldFrames: [MediaId: CGRect] = [:] + for mediaId in mediaIds { + if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) { + oldFrames[mediaId] = frame + } + } + + self.updateLayout() + + var newFrames: [MediaId: CGRect] = [:] + for mediaId in mediaIds { + if let frame = self.effectiveFrameForMedia(mediaId, detailsStateMaps: detailsStateMaps) { + newFrames[mediaId] = frame + } + } + + if let compensatedViewportTop = self.compensatedViewportTop(oldFrames: oldFrames, newFrames: newFrames, viewportTop: viewportTop) { + self.setViewportTop(compensatedViewportTop) + } + self.updateVisibleItems(visibleBounds: self.scrollNode.view.bounds) + } + + private func setViewportTop(_ viewportTop: CGFloat) { + let scrollView = self.scrollNode.view + let minOffsetY = -scrollView.contentInset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom) + let contentOffsetY = min(max(viewportTop - scrollView.contentInset.top, minOffsetY), maxOffsetY) + if contentOffsetY.isFinite { + let contentOffset = CGPoint(x: scrollView.contentOffset.x, y: contentOffsetY) + scrollView.contentOffset = contentOffset + self.previousContentOffset = contentOffset + self.updateNavigationBar() + } + } + + private func compensatedViewportTop(oldFrames: [MediaId: CGRect], newFrames: [MediaId: CGRect], viewportTop: CGFloat) -> CGFloat? { + var pairedFrames: [(old: CGRect, new: CGRect)] = [] + for (mediaId, oldFrame) in oldFrames { + if let newFrame = newFrames[mediaId] { + pairedFrames.append((oldFrame, newFrame)) + } + } + if pairedFrames.isEmpty { + return nil + } + + if let intersecting = pairedFrames + .filter({ $0.old.height > 0.0 && $0.new.height > 0.0 && viewportTop > $0.old.minY && viewportTop < $0.old.maxY }) + .max(by: { $0.old.minY < $1.old.minY }) { + let ratio = min(max((viewportTop - intersecting.old.minY) / intersecting.old.height, 0.0), 1.0) + return intersecting.new.minY + ratio * intersecting.new.height + } + + if let above = pairedFrames + .filter({ viewportTop >= $0.old.maxY }) + .max(by: { $0.old.maxY < $1.old.maxY }) { + return viewportTop + (above.new.maxY - above.old.maxY) + } + + return nil + } + + private func captureExpandedDetailsStateMaps() -> [String: [Int: Bool]] { + guard let currentLayout = self.currentLayout else { + return [:] + } + var result: [String: [Int: Bool]] = [:] + self.captureExpandedDetailsStateMaps(items: currentLayout.items, visibleItemsWithNodes: self.visibleItemsWithNodes, path: [], result: &result) + return result + } + + private func captureExpandedDetailsStateMaps(items: [InstantPageItem], visibleItemsWithNodes: [Int: InstantPageNode], path: [Int], result: inout [String: [Int: Bool]]) { + let detailsNodes = visibleItemsWithNodes.compactMap { $0.value as? InstantPageDetailsNode } + + var detailsIndex = -1 + for item in items { + guard let detailsItem = item as? InstantPageDetailsItem else { + continue + } + detailsIndex += 1 + + guard let detailsNode = detailsNodes.first(where: { $0.item === detailsItem }) else { + continue + } + let nextPath = path + [detailsIndex] + result[self.detailsStateKey(nextPath)] = detailsNode.contentNode.currentExpandedDetails ?? [:] + self.captureExpandedDetailsStateMaps(items: detailsItem.items, visibleItemsWithNodes: detailsNode.contentNode.visibleItemsWithNodes, path: nextPath, result: &result) + } + } + + private func detailsStateKey(_ path: [Int]) -> String { + if path.isEmpty { + return "" + } + return path.map(String.init).joined(separator: ".") + } + + private func effectiveFrameForMedia(_ mediaId: MediaId, detailsStateMaps: [String: [Int: Bool]]) -> CGRect? { + guard let currentLayout = self.currentLayout else { + return nil + } + return self.effectiveFrameForMedia(mediaId, items: currentLayout.items, origin: .zero, expandedDetails: self.currentExpandedDetails, path: [], detailsStateMaps: detailsStateMaps) + } + + private func effectiveFrameForMedia(_ mediaId: MediaId, items: [InstantPageItem], origin: CGPoint, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGRect? { + var collapseOffset: CGFloat = 0.0 + var detailsIndex = -1 + + for item in items { + if item is InstantPageDetailsItem { + detailsIndex += 1 + } + + var itemFrame = item.frame.offsetBy(dx: origin.x, dy: origin.y - collapseOffset) + if let detailsItem = item as? InstantPageDetailsItem { + let nextPath = path + [detailsIndex] + let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)] + let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded + let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight + collapseOffset += item.frame.height - height + itemFrame.size.height = height + + if expanded, let nestedFrame = self.effectiveFrameForMedia(mediaId, items: detailsItem.items, origin: CGPoint(x: itemFrame.minX, y: itemFrame.minY + detailsItem.titleHeight), expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) { + return nestedFrame + } + continue + } + + if self.itemContainsMedia(item, mediaId: mediaId) { + return itemFrame + } + } + + return nil + } + + private func effectiveContentHeight(items: [InstantPageItem], baseHeight: CGFloat, expandedDetails: [Int: Bool]?, path: [Int], detailsStateMaps: [String: [Int: Bool]]) -> CGFloat { + var contentHeight = baseHeight + var detailsIndex = -1 + + for item in items { + guard let detailsItem = item as? InstantPageDetailsItem else { + continue + } + detailsIndex += 1 + + let nextPath = path + [detailsIndex] + let nestedExpandedDetails = detailsStateMaps[self.detailsStateKey(nextPath)] + let expanded = expandedDetails?[detailsIndex] ?? detailsItem.initiallyExpanded + let height = expanded ? detailsItem.titleHeight + self.effectiveContentHeight(items: detailsItem.items, baseHeight: detailsItem.frame.height - detailsItem.titleHeight, expandedDetails: nestedExpandedDetails, path: nextPath, detailsStateMaps: detailsStateMaps) : detailsItem.titleHeight + contentHeight += -detailsItem.frame.height + height + } + + return contentHeight + } + + private func itemContainsMedia(_ item: InstantPageItem, mediaId: MediaId) -> Bool { + for media in item.medias { + if media.media.id == mediaId { + return true + } + } + return false + } + + private func resolvedWebPage() -> (webPage: TelegramMediaWebpage, instantPage: InstantPage?)? { + guard let (webPage, instantPage) = self.webPage else { + return nil + } + guard !self.resolvedExternalMediaDimensions.isEmpty, case let .Loaded(content) = webPage.content else { + return (webPage, instantPage) + } + + var instantPageUpdated = false + var effectiveInstantPage = instantPage + if let instantPage { + var media = instantPage.media + for (mediaId, currentMedia) in instantPage.media { + if let updatedMedia = self.updatedMediaIfNeeded(currentMedia) { + media[mediaId] = updatedMedia + instantPageUpdated = true + } + } + if instantPageUpdated { + effectiveInstantPage = InstantPage(blocks: instantPage.blocks, media: media, isComplete: instantPage.isComplete, rtl: instantPage.rtl, url: instantPage.url, views: instantPage.views) + } + } + + var imageUpdated = false + let effectiveImage = content.image.map { image -> TelegramMediaImage in + if let updated = self.updatedImageIfNeeded(image) { + imageUpdated = true + return updated + } else { + return image + } + } + + var fileUpdated = false + let effectiveFile = content.file.map { file -> TelegramMediaFile in + if let updated = self.updatedFileIfNeeded(file) { + fileUpdated = true + return updated + } else { + return file + } + } + + if !instantPageUpdated && !imageUpdated && !fileUpdated { + return (webPage, instantPage) + } + + let effectiveContent = TelegramMediaWebpageLoadedContent( + url: content.url, + displayUrl: content.displayUrl, + hash: content.hash, + type: content.type, + websiteName: content.websiteName, + title: content.title, + text: content.text, + embedUrl: content.embedUrl, + embedType: content.embedType, + embedSize: content.embedSize, + duration: content.duration, + author: content.author, + isMediaLargeByDefault: content.isMediaLargeByDefault, + imageIsVideoCover: content.imageIsVideoCover, + image: effectiveImage, + file: effectiveFile, + story: content.story, + attributes: content.attributes, + instantPage: effectiveInstantPage + ) + return (TelegramMediaWebpage(webpageId: webPage.webpageId, content: .Loaded(effectiveContent)), effectiveInstantPage) + } + + private func updatedMediaIfNeeded(_ media: Media) -> Media? { + if let image = media as? TelegramMediaImage { + return self.updatedImageIfNeeded(image) + } else if let file = media as? TelegramMediaFile { + return self.updatedFileIfNeeded(file) + } else { + return nil + } + } + + private func updatedImageIfNeeded(_ image: TelegramMediaImage) -> TelegramMediaImage? { + guard let dimensions = self.resolvedExternalMediaDimensions[image.imageId] else { + return nil + } + + var updatedRepresentations = image.representations + var didUpdate = false + for i in 0 ..< updatedRepresentations.count { + let representation = updatedRepresentations[i] + guard representation.resource is InstantPageExternalMediaResource, representation.dimensions != dimensions else { + continue + } + updatedRepresentations[i] = TelegramMediaImageRepresentation( + dimensions: dimensions, + resource: representation.resource, + progressiveSizes: representation.progressiveSizes, + immediateThumbnailData: representation.immediateThumbnailData, + hasVideo: representation.hasVideo, + isPersonal: representation.isPersonal, + typeHint: representation.typeHint + ) + didUpdate = true + } + + guard didUpdate else { + return nil + } + return TelegramMediaImage( + imageId: image.imageId, + representations: updatedRepresentations, + videoRepresentations: image.videoRepresentations, + immediateThumbnailData: image.immediateThumbnailData, + emojiMarkup: image.emojiMarkup, + reference: image.reference, + partialReference: image.partialReference, + flags: image.flags, + video: image.video + ) + } + + private func updatedFileIfNeeded(_ file: TelegramMediaFile) -> TelegramMediaFile? { + guard let dimensions = self.resolvedExternalMediaDimensions[file.fileId], file.resource is InstantPageExternalMediaResource else { + return nil + } + + let (attributes, didUpdate) = self.fileAttributesWithResolvedDimensions(file.attributes, dimensions: dimensions) + guard didUpdate else { + return nil + } + + return TelegramMediaFile( + fileId: file.fileId, + partialReference: file.partialReference, + resource: file.resource, + previewRepresentations: file.previewRepresentations, + videoThumbnails: file.videoThumbnails, + videoCover: file.videoCover, + immediateThumbnailData: file.immediateThumbnailData, + mimeType: file.mimeType, + size: file.size, + attributes: attributes, + alternativeRepresentations: file.alternativeRepresentations + ) + } + + private func fileAttributesWithResolvedDimensions(_ attributes: [TelegramMediaFileAttribute], dimensions: PixelDimensions) -> ([TelegramMediaFileAttribute], Bool) { + var updatedAttributes: [TelegramMediaFileAttribute] = [] + var didUpdate = false + var hasSizeAttribute = false + + for attribute in attributes { + switch attribute { + case let .ImageSize(size): + hasSizeAttribute = true + if size != dimensions { + updatedAttributes.append(.ImageSize(size: dimensions)) + didUpdate = true + } else { + updatedAttributes.append(attribute) + } + case let .Video(duration, size, flags, preloadSize, coverTime, videoCodec): + hasSizeAttribute = true + if size != dimensions { + updatedAttributes.append(.Video(duration: duration, size: dimensions, flags: flags, preloadSize: preloadSize, coverTime: coverTime, videoCodec: videoCodec)) + didUpdate = true + } else { + updatedAttributes.append(attribute) + } + default: + updatedAttributes.append(attribute) + } + } + + if !hasSizeAttribute { + updatedAttributes.append(.ImageSize(size: dimensions)) + didUpdate = true + } + + return (updatedAttributes, didUpdate) + } + private func longPressMedia(_ media: InstantPageMedia) { let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in if let strongSelf = self, case let .image(image) = media.media { diff --git a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift index e29fcab2d7..c2e49314be 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageItem.swift @@ -54,7 +54,7 @@ public final class InstantPageImageItem: InstantPageItem { public func matchesNode(_ node: InstantPageNode) -> Bool { if let node = node as? InstantPageImageNode { - return node.media == self.media + return instantPageMediaMatchesNodeIdentity(node.media, self.media) } else { return false } diff --git a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift index 76fc85e0fc..63fc534f52 100644 --- a/submodules/InstantPageUI/Sources/InstantPageImageNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageImageNode.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import ImageIO import AsyncDisplayKit import Display import TelegramCore @@ -21,7 +22,35 @@ private struct FetchControls { let cancel: () -> Void } -final class InstantPageImageNode: ASDisplayNode, InstantPageNode { +private enum ExternalImageLoadState { + case loading + case ready + case failed +} + +private func externalImagePixelDimensions(data: Data) -> PixelDimensions? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { + return nil + } + guard let pixelWidth = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.int32Value, + let pixelHeight = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.int32Value, + pixelWidth > 0, pixelHeight > 0 else { + return nil + } + + let orientation = imageOrientationFromSource(source) + switch orientation { + case .left, .right, .leftMirrored, .rightMirrored: + return PixelDimensions(width: pixelHeight, height: pixelWidth) + default: + return PixelDimensions(width: pixelWidth, height: pixelHeight) + } +} + +final class InstantPageImageNode: ASDisplayNode, InstantPageNode, InstantPageExternalMediaDimensionsNode { private let context: AccountContext private let webPage: TelegramMediaWebpage private var theme: InstantPageTheme @@ -32,8 +61,13 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { private let fit: Bool private let openMedia: (InstantPageMedia) -> Void private let longPressMedia: (InstantPageMedia) -> Void + private let getPreloadedResource: (String) -> Data? private var fetchControls: FetchControls? + private var externalImageLoadState: ExternalImageLoadState? + var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? + private var externalMediaDimensions: PixelDimensions? + private var didReportExternalMediaDimensions = false private let pinchContainerNode: PinchSourceContainerNode private let imageNode: TransformImageNode @@ -48,6 +82,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { private var statusDisposable = MetaDisposable() private var themeUpdated: Bool = false + private var externalMediaDimensionsUpdated: Bool = false init(context: AccountContext, sourceLocation: InstantPageSourceLocation, theme: InstantPageTheme, webPage: TelegramMediaWebpage, media: InstantPageMedia, attributes: [InstantPageImageAttribute], interactive: Bool, roundCorners: Bool, fit: Bool, openMedia: @escaping (InstantPageMedia) -> Void, longPressMedia: @escaping (InstantPageMedia) -> Void, activatePinchPreview: ((PinchSourceContainerNode) -> Void)?, pinchPreviewFinished: ((InstantPageNode) -> Void)?, getPreloadedResource: @escaping (String) -> Data?) { self.context = context @@ -60,6 +95,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { self.fit = fit self.openMedia = openMedia self.longPressMedia = longPressMedia + self.getPreloadedResource = getPreloadedResource self.pinchContainerNode = PinchSourceContainerNode() self.imageNode = TransformImageNode() @@ -72,33 +108,14 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { self.pinchContainerNode.contentNode.addSubnode(self.imageNode) self.addSubnode(self.pinchContainerNode) + if interactive, media.url != nil { + self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") + self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) + } + if case let .image(image) = media.media, let largest = largestImageRepresentation(image.representations) { if let externalResource = largest.resource as? InstantPageExternalMediaResource { - var url = externalResource.url - if !url.hasPrefix("http") && !url.hasPrefix("https") && url.hasPrefix("//") { - url = "https:\(url)" - } - let photoData: Signal, NoError> - if let preloadedData = getPreloadedResource(externalResource.url) { - photoData = .single(Tuple4(nil, preloadedData, .full, true)) - } else { - photoData = context.engine.resources.httpData(url: url, preserveExactUrl: true) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - |> map { data in - if let data { - return Tuple4(nil, data, .full, true) - } else { - return Tuple4(nil, nil, .full, false) - } - } - } - self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) - |> map { _, _, generate in - return generate - }) + self.loadExternalImage(resourceUrl: externalResource.url) } else { let imageReference = ImageMediaReference.webPage(webPage: WebpageReference(webPage), media: image) self.imageNode.setSignal(chatMessagePhoto(postbox: context.account.postbox, userLocation: sourceLocation.userLocation, photoReference: imageReference)) @@ -124,38 +141,13 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { } } })) - - if media.url != nil { - self.linkIconNode.image = UIImage(bundleImageName: "Instant View/ImageLink") - self.pinchContainerNode.contentNode.addSubnode(self.linkIconNode) - } - + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) } } } else if case let .file(file) = media.media { if let externalResource = file.resource as? InstantPageExternalMediaResource { - let photoData: Signal, NoError> - if let preloadedData = getPreloadedResource(externalResource.url) { - photoData = .single(Tuple4(nil, preloadedData, .full, true)) - } else { - photoData = context.engine.resources.httpData(url: externalResource.url, preserveExactUrl: true) - |> map(Optional.init) - |> `catch` { _ -> Signal in - return .single(nil) - } - |> map { data in - if let data { - return Tuple4(nil, data, .full, true) - } else { - return Tuple4(nil, nil, .full, false) - } - } - } - self.imageNode.setSignal(chatMessagePhotoInternal(photoData: photoData) - |> map { _, _, generate in - return generate - }) + self.loadExternalImage(resourceUrl: externalResource.url) } else { let fileReference = FileMediaReference.webPage(webPage: WebpageReference(webPage), media: file) if file.mimeType.hasPrefix("image/") { @@ -235,6 +227,114 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { } } + private func loadExternalImage(resourceUrl: String) { + self.externalImageLoadState = .loading + self.updateExternalImageLoadState() + + var requestUrl = resourceUrl + if !requestUrl.hasPrefix("http") && !requestUrl.hasPrefix("https") && requestUrl.hasPrefix("//") { + requestUrl = "https:\(requestUrl)" + } + + let photoData: Signal, NoError> + if let preloadedData = self.getPreloadedResource(resourceUrl) { + photoData = .single(Tuple4(nil, preloadedData, .full, true)) + } else { + photoData = self.context.engine.resources.httpData(url: requestUrl, preserveExactUrl: true) + |> map(Optional.init) + |> `catch` { _ -> Signal in + return .single(nil) + } + |> map { data in + if let data { + return Tuple4(nil, data, .full, true) + } else { + return Tuple4(nil, nil, .full, false) + } + } + } + + let stateAwarePhotoData = photoData + |> deliverOnMainQueue + |> afterNext { [weak self] value in + guard let strongSelf = self else { + return + } + if let data = value._1 ?? value._0, UIImage(data: data) != nil { + if let dimensions = externalImagePixelDimensions(data: data) { + strongSelf.externalMediaDimensions = dimensions + strongSelf.externalMediaDimensionsUpdated = true + strongSelf.maybeUpdateExternalMediaDimensions(dimensions) + } + strongSelf.externalImageLoadState = .ready + strongSelf.setNeedsLayout() + } else { + strongSelf.externalImageLoadState = .failed + } + strongSelf.updateExternalImageLoadState() + } + + self.imageNode.setSignal(chatMessagePhotoInternal(photoData: stateAwarePhotoData) + |> map { _, _, generate in + return generate + }) + + self.fetchControls = FetchControls(fetch: { [weak self] _ in + self?.loadExternalImage(resourceUrl: resourceUrl) + }, cancel: {}) + } + + private func currentMediaDimensions() -> PixelDimensions? { + if case let .image(image) = self.media.media, let largest = largestImageRepresentation(image.representations) { + return largest.dimensions + } else if case let .file(file) = self.media.media { + return file.dimensions + } else { + return nil + } + } + + private func effectiveMediaDimensions() -> PixelDimensions? { + return self.externalMediaDimensions ?? self.currentMediaDimensions() + } + + private func maybeUpdateExternalMediaDimensions(_ dimensions: PixelDimensions) { + guard !self.didReportExternalMediaDimensions, let mediaId = self.media.media.id else { + return + } + if let currentDimensions = self.currentMediaDimensions(), currentDimensions == dimensions { + return + } + self.didReportExternalMediaDimensions = true + self.updateExternalMediaDimensions?(mediaId, dimensions) + } + + private func updateExternalImageLoadState() { + guard let externalImageLoadState = self.externalImageLoadState else { + return + } + + if self.statusNode.supernode == nil { + self.pinchContainerNode.contentNode.addSubnode(self.statusNode) + } + + let state: RadialStatusNodeState + switch externalImageLoadState { + case .loading: + state = .progress(color: .white, lineWidth: nil, value: nil, cancelEnabled: false, animateRotation: true) + case .ready: + state = .none + case .failed: + state = .none + } + + self.statusNode.transitionToState(state, completion: { [weak statusNode] in + if state == .none { + statusNode?.removeFromSupernode() + } + }) + } + private func updateFetchStatus() { var state: RadialStatusNodeState = .none if let fetchStatus = self.fetchStatus { @@ -260,19 +360,20 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { let size = self.bounds.size - if self.currentSize != size || self.themeUpdated { + if self.currentSize != size || self.themeUpdated || self.externalMediaDimensionsUpdated { self.currentSize = size self.themeUpdated = false + self.externalMediaDimensionsUpdated = false self.pinchContainerNode.frame = CGRect(origin: CGPoint(), size: size) self.pinchContainerNode.update(size: size, transition: .immediate) self.imageNode.frame = CGRect(origin: CGPoint(), size: size) - let radialStatusSize: CGFloat = 50.0 + let radialStatusSize: CGFloat = max(18.0, min(50.0, floor(min(size.width, size.height) * 0.7))) self.statusNode.frame = CGRect(x: floorToScreenPixels((size.width - radialStatusSize) / 2.0), y: floorToScreenPixels((size.height - radialStatusSize) / 2.0), width: radialStatusSize, height: radialStatusSize) - if case let .image(image) = self.media.media, let largest = largestImageRepresentation(image.representations) { - let imageSize = largest.dimensions.cgSize.aspectFilled(size) + if case .image = self.media.media, let dimensions = self.effectiveMediaDimensions() { + let imageSize = dimensions.cgSize.aspectFilled(size) let boundingSize = size let radius: CGFloat = self.roundCorners ? floor(min(imageSize.width, imageSize.height) / 2.0) : 0.0 let makeLayout = self.imageNode.asyncLayout() @@ -280,7 +381,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { apply() self.linkIconNode.frame = CGRect(x: size.width - 38.0, y: 14.0, width: 24.0, height: 24.0) - } else if case let .file(file) = self.media.media, let dimensions = file.dimensions { + } else if case let .file(file) = self.media.media, let dimensions = self.effectiveMediaDimensions() { let emptyColor = file.mimeType.hasPrefix("image/") ? self.theme.imageTintColor : nil let imageSize = dimensions.cgSize.aspectFilled(size) @@ -318,7 +419,7 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { } func transitionNode(media: InstantPageMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - if media == self.media { + if instantPageMediaMatchesNodeIdentity(media, self.media) { let imageNode = self.imageNode return (self.imageNode, self.imageNode.bounds, { [weak imageNode] in return (imageNode?.view.snapshotContentTree(unhide: true), nil) @@ -329,14 +430,32 @@ final class InstantPageImageNode: ASDisplayNode, InstantPageNode { } func updateHiddenMedia(media: InstantPageMedia?) { - self.imageNode.isHidden = self.media == media + if let media { + self.imageNode.isHidden = instantPageMediaMatchesNodeIdentity(self.media, media) + } else { + self.imageNode.isHidden = false + } self.statusNode.isHidden = self.imageNode.isHidden + self.linkIconNode.isHidden = self.imageNode.isHidden } @objc private func tapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: if let (gesture, _) = recognizer.lastRecognizedGestureAndLocation { + if let externalImageLoadState = self.externalImageLoadState { + switch externalImageLoadState { + case .loading: + return + case .failed: + if case .tap = gesture { + self.fetchControls?.fetch(true) + } + return + case .ready: + break + } + } if let fetchStatus = self.fetchStatus { switch fetchStatus { case .Local: diff --git a/submodules/InstantPageUI/Sources/InstantPageMedia.swift b/submodules/InstantPageUI/Sources/InstantPageMedia.swift index ff9b2bc3f5..a7813f63bd 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMedia.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMedia.swift @@ -20,3 +20,28 @@ public struct InstantPageMedia: Equatable { return lhs.index == rhs.index && lhs.media == rhs.media && lhs.url == rhs.url && lhs.caption == rhs.caption && lhs.credit == rhs.credit } } + +func instantPageMediaMatchesNodeIdentity(_ lhs: InstantPageMedia, _ rhs: InstantPageMedia) -> Bool { + if lhs.index != rhs.index { + return false + } + if lhs.url != rhs.url || lhs.caption != rhs.caption || lhs.credit != rhs.credit { + return false + } + if let lhsId = lhs.media.id, let rhsId = rhs.media.id { + return lhsId == rhsId + } + return lhs == rhs +} + +func instantPageMediaArraysMatchNodeIdentity(_ lhs: [InstantPageMedia], _ rhs: [InstantPageMedia]) -> Bool { + if lhs.count != rhs.count { + return false + } + for i in 0 ..< lhs.count { + if !instantPageMediaMatchesNodeIdentity(lhs[i], rhs[i]) { + return false + } + } + return true +} diff --git a/submodules/InstantPageUI/Sources/InstantPageNode.swift b/submodules/InstantPageUI/Sources/InstantPageNode.swift index c235614961..fb011c454f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageNode.swift @@ -2,8 +2,13 @@ import Foundation import UIKit import AsyncDisplayKit import Display +import TelegramCore import TelegramPresentationData +public protocol InstantPageExternalMediaDimensionsNode: AnyObject { + var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? { get set } +} + public protocol InstantPageNode: ASDisplayNode { func updateIsVisible(_ isVisible: Bool) diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift index d9a39cabfc..560185cd86 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItem.swift @@ -30,7 +30,7 @@ final class InstantPageSlideshowItem: InstantPageItem { func matchesNode(_ node: InstantPageNode) -> Bool { if let node = node as? InstantPageSlideshowNode { - return self.medias == node.medias + return instantPageMediaArraysMatchNodeIdentity(self.medias, node.medias) } else { return false } @@ -55,4 +55,3 @@ final class InstantPageSlideshowItem: InstantPageItem { func drawInTile(context: CGContext) { } } - diff --git a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift index 6baa616027..6fcc36030f 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSlideshowItemNode.swift @@ -61,6 +61,12 @@ private final class InstantPageSlideshowItemNode: ASDisplayNode { } return nil } + + func updateExternalMediaDimensions(_ update: ((EngineMedia.Id, PixelDimensions) -> Void)?) { + if let node = self.contentNode as? InstantPageImageNode { + node.updateExternalMediaDimensions = update + } + } } private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDelegate { @@ -88,6 +94,13 @@ private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDe } private var containerLayout: ContainerViewLayout? + var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? { + didSet { + for node in self.itemNodes { + node.updateExternalMediaDimensions(self.updateExternalMediaDimensions) + } + } + } var centralItemIndexUpdated: (Int?) -> Void = { _ in } @@ -195,6 +208,7 @@ private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDe } let node = InstantPageSlideshowItemNode(contentNode: contentNode) + node.updateExternalMediaDimensions(self.updateExternalMediaDimensions) node.index = index return node @@ -380,8 +394,13 @@ private final class InstantPageSlideshowPagerNode: ASDisplayNode, ASScrollViewDe } } -final class InstantPageSlideshowNode: ASDisplayNode, InstantPageNode { +final class InstantPageSlideshowNode: ASDisplayNode, InstantPageNode, InstantPageExternalMediaDimensionsNode { var medias: [InstantPageMedia] = [] + var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? { + didSet { + self.pagerNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } + } private let pagerNode: InstantPageSlideshowPagerNode private let pageControlNode: PageControlNode diff --git a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift index 30bdf1c6d9..d3633c7f9b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageSubContentNode.swift @@ -8,7 +8,7 @@ import TelegramPresentationData import TelegramUIPreferences import AccountContext -final class InstantPageSubContentNode : ASDisplayNode { +final class InstantPageSubContentNode : ASDisplayNode, InstantPageExternalMediaDimensionsNode { private let context: AccountContext private let strings: PresentationStrings private let nameDisplayOrder: PresentationPersonNameOrder @@ -32,6 +32,13 @@ final class InstantPageSubContentNode : ASDisplayNode { var currentDetailsItems: [InstantPageDetailsItem] = [] var requestLayoutUpdate: ((Bool) -> Void)? + var updateExternalMediaDimensions: ((EngineMedia.Id, PixelDimensions) -> Void)? { + didSet { + for (_, itemNode) in self.visibleItemsWithNodes { + self.applyExternalMediaDimensionsUpdater(to: itemNode) + } + } + } var currentLayout: InstantPageLayout let contentSize: CGSize @@ -209,6 +216,7 @@ final class InstantPageSubContentNode : ASDisplayNode { topNode = newNode self.visibleItemsWithNodes[itemIndex] = newNode itemNode = newNode + self.applyExternalMediaDimensionsUpdater(to: newNode) if let itemNode = itemNode as? InstantPageDetailsNode { itemNode.requestLayoutUpdate = { [weak self] animated in @@ -289,6 +297,16 @@ final class InstantPageSubContentNode : ASDisplayNode { } } + private func applyExternalMediaDimensionsUpdater(to itemNode: InstantPageNode) { + if let itemNode = itemNode as? InstantPageImageNode { + itemNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } else if let itemNode = itemNode as? InstantPageDetailsNode { + itemNode.contentNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } else if let itemNode = itemNode as? InstantPageSlideshowNode { + itemNode.updateExternalMediaDimensions = self.updateExternalMediaDimensions + } + } + private func updateWebEmbedHeight(_ index: Int, _ height: CGFloat) { // let currentHeight = self.currentWebEmbedHeights[index] // if height != currentHeight { diff --git a/submodules/InstantPageUI/Sources/InstantPageTileNode.swift b/submodules/InstantPageUI/Sources/InstantPageTileNode.swift index c16bcac1dd..3ed1a30426 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTileNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTileNode.swift @@ -15,10 +15,12 @@ private final class InstantPageTileNodeParameters: NSObject { } public final class InstantPageTileNode: ASDisplayNode { - private let tile: InstantPageTile + private var tile: InstantPageTile + private var tileBackgroundColor: UIColor public init(tile: InstantPageTile, backgroundColor: UIColor) { self.tile = tile + self.tileBackgroundColor = backgroundColor super.init() @@ -27,8 +29,15 @@ public final class InstantPageTileNode: ASDisplayNode { self.backgroundColor = backgroundColor } + public func update(tile: InstantPageTile, backgroundColor: UIColor) { + self.tile = tile + self.tileBackgroundColor = backgroundColor + self.backgroundColor = backgroundColor + self.setNeedsDisplay() + } + public override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { - return InstantPageTileNodeParameters(tile: self.tile, backgroundColor: self.backgroundColor ?? UIColor.white) + return InstantPageTileNodeParameters(tile: self.tile, backgroundColor: self.tileBackgroundColor) } @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { diff --git a/submodules/LocationUI/BUILD b/submodules/LocationUI/BUILD index 8843a0ed2b..321fd7dd3a 100644 --- a/submodules/LocationUI/BUILD +++ b/submodules/LocationUI/BUILD @@ -48,6 +48,7 @@ swift_library( "//submodules/Components/BundleIconComponent", "//submodules/TelegramUI/Components/GlassBackgroundComponent", "//submodules/TelegramUI/Components/GlassBarButtonComponent", + "//submodules/TelegramUI/Components/GlassControls", "//submodules/TelegramUI/Components/EdgeEffect", "//submodules/TelegramUI/Components/SearchInputPanelComponent", "//submodules/TelegramUI/Components/ButtonComponent", diff --git a/submodules/LocationUI/Sources/LocationInfoListItem.swift b/submodules/LocationUI/Sources/LocationInfoListItem.swift index fda4a00e51..032f1f621c 100644 --- a/submodules/LocationUI/Sources/LocationInfoListItem.swift +++ b/submodules/LocationUI/Sources/LocationInfoListItem.swift @@ -100,7 +100,7 @@ public final class LocationInfoListItemNode: ListViewItemNode { super.init(layerBacked: false, rotated: false, seeThrough: false) - self.addSubnode(self.backgroundNode) + //self.addSubnode(self.backgroundNode) self.addSubnode(self.buttonNode) self.addSubnode(self.venueIconNode) diff --git a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift index 34d88662f9..c3ef2e37d1 100644 --- a/submodules/LocationUI/Sources/LocationMapHeaderNode.swift +++ b/submodules/LocationUI/Sources/LocationMapHeaderNode.swift @@ -57,7 +57,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { private let options: ComponentView? - private let optionsBackgroundView: GlassBackgroundView? + private let optionsBackgroundView: GlassContextExtractableContainer? private let optionsBackgroundNode: ASImageNode private let optionsSeparatorNode: ASDisplayNode private let optionsSecondSeparatorNode: ASDisplayNode @@ -66,7 +66,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { private let notificationButtonNode: HighlightableButtonNode private let placesBackgroundView: GlassBackgroundView? private let placesBackgroundNode: ASImageNode - private let placesButtonNode: HighlightableButtonNode + private let placesButtonNode: HighlightTrackingButtonNode private let shadowNode: ASImageNode private var validLayout: (ContainerViewLayout, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGSize)? @@ -132,7 +132,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { self.placesBackgroundNode.image = generateBackgroundImage(theme: presentationData.theme) self.placesBackgroundNode.isUserInteractionEnabled = true - self.placesButtonNode = HighlightableButtonNode() + self.placesButtonNode = HighlightTrackingButtonNode() self.placesButtonNode.setTitle(presentationData.strings.Map_PlacesInThisArea, with: Font.medium(17.0), with: self.glass ? presentationData.theme.rootController.navigationBar.primaryTextColor : buttonColor, for: .normal) self.shadowNode = ASImageNode() @@ -142,7 +142,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { self.shadowNode.image = generateShadowImage(theme: presentationData.theme, highlighted: false) if glass { - self.optionsBackgroundView = GlassBackgroundView() + self.optionsBackgroundView = GlassContextExtractableContainer() self.optionsBackgroundNode.image = nil self.placesBackgroundView = GlassBackgroundView() @@ -169,16 +169,16 @@ public final class LocationMapHeaderNode: ASDisplayNode { self.view.addSubview(optionsBackgroundView) } self.addSubnode(self.optionsBackgroundNode) - self.optionsBackgroundNode.addSubnode(self.optionsSeparatorNode) - self.optionsBackgroundNode.addSubnode(self.optionsSecondSeparatorNode) - self.optionsBackgroundNode.addSubnode(self.infoButtonNode) - self.optionsBackgroundNode.addSubnode(self.locationButtonNode) - self.optionsBackgroundNode.addSubnode(self.notificationButtonNode) + self.optionsBackgroundView?.contentView.addSubview(self.optionsSeparatorNode.view) + self.optionsBackgroundView?.contentView.addSubview(self.optionsSecondSeparatorNode.view) + self.optionsBackgroundView?.contentView.addSubview(self.infoButtonNode.view) + self.optionsBackgroundView?.contentView.addSubview(self.locationButtonNode.view) + self.optionsBackgroundView?.contentView.addSubview(self.notificationButtonNode.view) } } self.addSubnode(self.placesBackgroundNode) - self.placesBackgroundNode.addSubnode(self.placesButtonNode) + self.placesBackgroundView?.contentView.addSubview(self.placesButtonNode.view) //self.addSubnode(self.shadowNode) self.infoButtonNode.addTarget(self, action: #selector(self.infoPressed), forControlEvents: .touchUpInside) @@ -252,16 +252,16 @@ public final class LocationMapHeaderNode: ASDisplayNode { let inset: CGFloat = 6.0 let placesButtonSize = CGSize(width: 180.0 + panelInset * 2.0, height: 45.0 + panelInset * 2.0) - let placesButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - placesButtonSize.width) / 2.0), y: navigationBarHeight + topPadding - 6.0), size: placesButtonSize) + let placesButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - placesButtonSize.width) / 2.0), y: navigationBarHeight + topPadding - 6.0), size: placesButtonSize).insetBy(dx: 5.0, dy: 6.0) transition.updateFrame(node: self.placesBackgroundNode, frame: placesButtonFrame) if let placesBackgroundView = self.placesBackgroundView { - let backgroundViewFrame = CGRect(origin: .zero, size: placesButtonFrame.size).insetBy(dx: 5.0, dy: 6.0) + let backgroundViewFrame = CGRect(origin: .zero, size: placesButtonFrame.size) transition.updateFrame(view: placesBackgroundView, frame: backgroundViewFrame) - placesBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.height * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), transition: .immediate) + placesBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.height * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: .immediate) } - transition.updateFrame(node: self.placesButtonNode, frame: CGRect(origin: CGPoint(), size: placesButtonSize)) + transition.updateFrame(node: self.placesButtonNode, frame: CGRect(origin: CGPoint(), size: placesButtonFrame.size)) transition.updateAlpha(node: self.placesBackgroundNode, alpha: self.displayingPlacesButton ? 1.0 : 0.0) transition.updateAlpha(node: self.placesButtonNode, alpha: self.displayingPlacesButton ? 1.0 : 0.0) @@ -327,7 +327,7 @@ public final class LocationMapHeaderNode: ASDisplayNode { if let optionsBackgroundView = self.optionsBackgroundView { let backgroundViewFrame = backgroundFrame.insetBy(dx: 4.0, dy: 4.0) transition.updateFrame(view: optionsBackgroundView, frame: backgroundViewFrame) - optionsBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.width * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), transition: .immediate) + optionsBackgroundView.update(size: backgroundViewFrame.size, cornerRadius: backgroundViewFrame.width * 0.5, isDark: self.presentationData.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: .immediate) } let alphaTransition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) diff --git a/submodules/LocationUI/Sources/LocationViewController.swift b/submodules/LocationUI/Sources/LocationViewController.swift index df3b15b229..4faec7447e 100644 --- a/submodules/LocationUI/Sources/LocationViewController.swift +++ b/submodules/LocationUI/Sources/LocationViewController.swift @@ -11,7 +11,6 @@ import AppBundle import CoreLocation import PresentationDataUtils import OpenInExternalAppUI - import DeviceAccess import UndoUI import MapKit @@ -32,12 +31,6 @@ public class LocationViewParams { } } -enum LocationViewRightBarButton { - case none - case share - case showAll -} - class LocationViewInteraction { let toggleMapModeSelection: () -> Void let updateMapMode: (LocationMapMode) -> Void @@ -49,10 +42,9 @@ class LocationViewInteraction { let updateSendActionHighlight: (Bool) -> Void let sendLiveLocation: (Int32?, Bool, EngineMessage.Id?) -> Void let stopLiveLocation: () -> Void - let updateRightBarButton: (LocationViewRightBarButton) -> Void let present: (ViewController) -> Void - init(toggleMapModeSelection: @escaping () -> Void, updateMapMode: @escaping (LocationMapMode) -> Void, toggleTrackingMode: @escaping () -> Void, goToCoordinate: @escaping (CLLocationCoordinate2D) -> Void, requestDirections: @escaping (TelegramMediaMap, String?, OpenInLocationDirections) -> Void, share: @escaping () -> Void, setupProximityNotification: @escaping (Bool, EngineMessage.Id?) -> Void, updateSendActionHighlight: @escaping (Bool) -> Void, sendLiveLocation: @escaping (Int32?, Bool, EngineMessage.Id?) -> Void, stopLiveLocation: @escaping () -> Void, updateRightBarButton: @escaping (LocationViewRightBarButton) -> Void, present: @escaping (ViewController) -> Void) { + init(toggleMapModeSelection: @escaping () -> Void, updateMapMode: @escaping (LocationMapMode) -> Void, toggleTrackingMode: @escaping () -> Void, goToCoordinate: @escaping (CLLocationCoordinate2D) -> Void, requestDirections: @escaping (TelegramMediaMap, String?, OpenInLocationDirections) -> Void, share: @escaping () -> Void, setupProximityNotification: @escaping (Bool, EngineMessage.Id?) -> Void, updateSendActionHighlight: @escaping (Bool) -> Void, sendLiveLocation: @escaping (Int32?, Bool, EngineMessage.Id?) -> Void, stopLiveLocation: @escaping () -> Void, present: @escaping (ViewController) -> Void) { self.toggleMapModeSelection = toggleMapModeSelection self.updateMapMode = updateMapMode self.toggleTrackingMode = toggleTrackingMode @@ -63,7 +55,6 @@ class LocationViewInteraction { self.updateSendActionHighlight = updateSendActionHighlight self.sendLiveLocation = sendLiveLocation self.stopLiveLocation = stopLiveLocation - self.updateRightBarButton = updateRightBarButton self.present = present } } @@ -83,9 +74,7 @@ public final class LocationViewController: ViewController { private let locationManager = LocationManager() private var interaction: LocationViewInteraction? - - private var rightBarButtonAction: LocationViewRightBarButton = .none - + public var dismissed: () -> Void = {} public init(context: AccountContext, updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, subject: EngineMessage, isStoryLocation: Bool = false, isPreview: Bool = false, params: LocationViewParams) { @@ -97,33 +86,19 @@ public final class LocationViewController: ViewController { self.presentationData = updatedPresentationData?.initial ?? context.sharedContext.currentPresentationData.with { $0 } - let navigationBarPresentationData: NavigationBarPresentationData? - if !isPreview { - navigationBarPresentationData = NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: self.presentationData.theme).withUpdatedSeparatorColor(.clear), strings: NavigationBarStrings(presentationStrings: self.presentationData.strings)) - } else { - navigationBarPresentationData = nil - } + super.init(navigationBarPresentationData: nil) - super.init(navigationBarPresentationData: navigationBarPresentationData) + self._hasGlassStyle = true self.navigationPresentation = .modal - - if !self.isPreview { - self.title = self.presentationData.strings.Map_LocationTitle - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Close, style: .plain, target: self, action: #selector(self.cancelPressed)) - } - + self.presentationDataDisposable = ((updatedPresentationData?.signal ?? context.sharedContext.presentationData) |> deliverOnMainQueue).start(next: { [weak self] presentationData in guard let strongSelf = self, strongSelf.presentationData.theme !== presentationData.theme else { return } strongSelf.presentationData = presentationData - - strongSelf.navigationBar?.updatePresentationData(NavigationBarPresentationData(theme: NavigationBarTheme(rootControllerTheme: strongSelf.presentationData.theme).withUpdatedSeparatorColor(.clear), strings: NavigationBarStrings(presentationStrings: strongSelf.presentationData.strings)), transition: .immediate) - - strongSelf.updateRightBarButton() - + if strongSelf.isNodeLoaded { strongSelf.controllerNode.updatePresentationData(presentationData) } @@ -466,15 +441,6 @@ public final class LocationViewController: ViewController { }, stopLiveLocation: { [weak self] in params.stopLiveLocation(nil) self?.dismiss() - }, updateRightBarButton: { [weak self] action in - guard let strongSelf = self else { - return - } - - if action != strongSelf.rightBarButtonAction { - strongSelf.rightBarButtonAction = action - strongSelf.updateRightBarButton() - } }, present: { [weak self] c in if let strongSelf = self { strongSelf.present(c, in: .window(.root)) @@ -515,7 +481,7 @@ public final class LocationViewController: ViewController { return } - self.displayNode = LocationViewControllerNode(context: self.context, presentationData: self.presentationData, subject: self.subject, interaction: interaction, locationManager: self.locationManager, isStoryLocation: self.isStoryLocation, isPreview: self.isPreview) + self.displayNode = LocationViewControllerNode(context: self.context, controller: self, presentationData: self.presentationData, subject: self.subject, interaction: interaction, locationManager: self.locationManager, isStoryLocation: self.isStoryLocation, isPreview: self.isPreview) self.displayNodeDidLoad() self.controllerNode.onAnnotationsReady = { [weak self] in @@ -528,39 +494,12 @@ public final class LocationViewController: ViewController { self.controllerNode.headerNode.mapNode.disableHorizontalTransitionGesture = self.isStoryLocation } - private func updateRightBarButton() { - guard !self.isPreview else { - return - } - switch self.rightBarButtonAction { - case .none: - self.navigationItem.rightBarButtonItem = nil - case .share: - self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: PresentationResourcesRootController.navigationShareIcon(self.presentationData.theme), style: .plain, target: self, action: #selector(self.sharePressed)) - self.navigationItem.rightBarButtonItem?.accessibilityLabel = self.presentationData.strings.VoiceOver_MessageContextShare - case .showAll: - self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Map_LiveLocationShowAll, style: .plain, target: self, action: #selector(self.showAllPressed)) - } - } - override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) } - - @objc private func cancelPressed() { - self.dismiss() - } - - @objc private func sharePressed() { - self.interaction?.share() - } - - @objc private func showAllPressed() { - self.controllerNode.showAll() - } - + private var didDismiss = false public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) diff --git a/submodules/LocationUI/Sources/LocationViewControllerNode.swift b/submodules/LocationUI/Sources/LocationViewControllerNode.swift index 2dedaed1a4..b2907103ef 100644 --- a/submodules/LocationUI/Sources/LocationViewControllerNode.swift +++ b/submodules/LocationUI/Sources/LocationViewControllerNode.swift @@ -17,6 +17,11 @@ import CoreLocation import Geocoding import DeviceAccess import TooltipUI +import ComponentFlow +import GlassControls +import BundleIconComponent +import EdgeEffect +import MultilineTextComponent func getLocation(from message: EngineMessage) -> TelegramMediaMap? { if let poll = message.media.first(where: { $0 is TelegramMediaPoll } ) as? TelegramMediaPoll, let map = poll.attachedMedia as? TelegramMediaMap { @@ -212,6 +217,12 @@ private func preparedTransition(from fromEntries: [LocationViewEntry], to toEntr return LocationViewTransaction(deletions: deletions, insertions: insertions, updates: updates, gotTravelTimes: gotTravelTimes, count: toEntries.count, animated: animated) } +enum LocationViewRightBarButton { + case none + case share + case showAll +} + public enum LocationViewLocation: Equatable { case initial case user @@ -268,6 +279,7 @@ public struct LocationViewState { final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationManagerDelegate { private let context: AccountContext + private weak var controller: LocationViewController? private var presentationData: PresentationData private let presentationDataPromise: Promise private var subject: EngineMessage @@ -276,9 +288,14 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan private let isStoryLocation: Bool private let isPreview: Bool + private var rightBarButtonAction: LocationViewRightBarButton = .none + + private let topEdgeEffectView = EdgeEffectView() + private let buttons = ComponentView() + private let title = ComponentView() + private let listNode: ListView let headerNode: LocationMapHeaderNode - private let optionsNode: LocationOptionsNode private var enqueuedTransitions: [LocationViewTransaction] = [] @@ -302,8 +319,9 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan } private let travelTimesPromise = Promise<[EngineMessage.Id: (Double, ExpectedTravelTime, ExpectedTravelTime, ExpectedTravelTime)]>([:]) - init(context: AccountContext, presentationData: PresentationData, subject: EngineMessage, interaction: LocationViewInteraction, locationManager: LocationManager, isStoryLocation: Bool, isPreview: Bool) { + init(context: AccountContext, controller: LocationViewController, presentationData: PresentationData, subject: EngineMessage, interaction: LocationViewInteraction, locationManager: LocationManager, isStoryLocation: Bool, isPreview: Bool) { self.context = context + self.controller = controller self.presentationData = presentationData self.presentationDataPromise = Promise(presentationData) self.subject = subject @@ -316,7 +334,7 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan self.statePromise = Promise(self.state) self.listNode = ListViewImpl() - self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + self.listNode.backgroundColor = .clear //self.presentationData.theme.list.plainBackgroundColor self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3) self.listNode.verticalScrollIndicatorFollowsOverscroll = true self.listNode.accessibilityPageScrolledString = { row, count in @@ -326,29 +344,24 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan var setupProximityNotificationImpl: ((Bool) -> Void)? self.headerNode = LocationMapHeaderNode( presentationData: presentationData, - glass: false, - isPreview: true, + glass: true, + isPreview: self.isPreview, toggleMapModeSelection: interaction.toggleMapModeSelection, updateMapMode: interaction.updateMapMode, goToUserLocation: interaction.toggleTrackingMode, setupProximityNotification: { reset in - setupProximityNotificationImpl?(reset) - }) - //self.headerNode.mapNode.isRotateEnabled = false - - self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode) - + setupProximityNotificationImpl?(reset) + } + ) + super.init() - self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + self.backgroundColor = .red // self.presentationData.theme.list.plainBackgroundColor if !self.isPreview { self.addSubnode(self.listNode) } self.addSubnode(self.headerNode) - if !self.isPreview { - self.addSubnode(self.optionsNode) - } let userLocation: Signal = .single(nil) |> then( @@ -707,7 +720,7 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan } else { rightBarButtonAction = .share } - strongSelf.interaction.updateRightBarButton(rightBarButtonAction) + strongSelf.rightBarButtonAction = rightBarButtonAction if let (layout, navigationBarHeight) = strongSelf.validLayout { var updateLayout = false @@ -800,10 +813,9 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan self.presentationData = presentationData self.presentationDataPromise.set(.single(presentationData)) - self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor - self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor + self.backgroundColor = .red //self.presentationData.theme.list.plainBackgroundColor + self.listNode.backgroundColor = .clear // self.presentationData.theme.list.plainBackgroundColor self.headerNode.updatePresentationData(self.presentationData) - self.optionsNode.updatePresentationData(self.presentationData) } func updateState(_ f: (LocationViewState) -> LocationViewState) { @@ -970,10 +982,10 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan } else { headerHeight = topInset + overlap } - let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: headerHeight)) + let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: layout.size.height)) transition.updateFrame(node: self.headerNode, frame: headerFrame) - self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, controlsTopPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, controlsBottomPadding: 0.0, offset: 0.0, size: headerFrame.size, transition: transition) + self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, controlsTopPadding: 0.0, controlsBottomPadding: 0.0, offset: 0.0, size: CGSize(width: headerFrame.width, height: headerHeight), transition: transition) let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) @@ -982,18 +994,118 @@ final class LocationViewControllerNode: ViewControllerTracingNode, CLLocationMan let listFrame: CGRect = CGRect(origin: CGPoint(), size: layout.size) transition.updateFrame(node: self.listNode, frame: listFrame) + + if !self.isPreview { + let topEdgeEffectFrame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: 80.0)) + transition.updateFrame(view: self.topEdgeEffectView, frame: topEdgeEffectFrame) + self.topEdgeEffectView.update(content: self.headerNode.mapNode.mapMode == .map ? self.presentationData.theme.list.plainBackgroundColor : .clear, blur: true, alpha: 0.65, rect: topEdgeEffectFrame, edge: .top, edgeSize: topEdgeEffectFrame.height, transition: ComponentTransition(transition)) + if self.topEdgeEffectView.superview == nil { + self.view.addSubview(self.topEdgeEffectView) + } + + let leftControlItems: [GlassControlGroupComponent.Item] = [ + GlassControlGroupComponent.Item( + id: AnyHashable("close"), + content: .icon("Navigation/Close"), + action: { [weak self] in + guard let self else { + return + } + self.controller?.dismiss() + } + ) + ] + var rightControlItems: [GlassControlGroupComponent.Item] = [] + switch self.rightBarButtonAction { + case .none: + break + case .share: + rightControlItems.append( + GlassControlGroupComponent.Item( + id: AnyHashable("share"), + content: .icon("Navigation/Share"), + action: { [weak self] in + guard let self else { + return + } + self.interaction.share() + } + ) + ) + case .showAll: + rightControlItems.append( + GlassControlGroupComponent.Item( + id: AnyHashable("share"), + content: .text(self.presentationData.strings.Map_LiveLocationShowAll), + action: { [weak self] in + guard let self else { + return + } + self.showAll() + } + ) + ) + } + + let barButtonSideInset: CGFloat = 16.0 + let buttonsSize = self.buttons.update( + transition: ComponentTransition(transition), + component: AnyComponent(GlassControlPanelComponent( + theme: self.presentationData.theme, + leftItem: GlassControlPanelComponent.Item( + items: leftControlItems, + background: .panel + ), + centralItem: nil, + rightItem: rightControlItems.isEmpty ? nil : GlassControlPanelComponent.Item( + items: rightControlItems, + background: .panel + ), + centerAlignmentIfPossible: true, + isDark: self.presentationData.theme.overallDarkAppearance + )), + environment: {}, + containerSize: CGSize(width: layout.size.width - barButtonSideInset * 2.0 - layout.safeInsets.left - layout.safeInsets.right, height: 44.0) + ) + let buttonsFrame = CGRect(origin: CGPoint(x: barButtonSideInset + layout.safeInsets.left, y: barButtonSideInset), size: buttonsSize) + if let view = self.buttons.view { + if view.superview == nil { + self.view.addSubview(view) + } + view.bounds = CGRect(origin: .zero, size: buttonsFrame.size) + view.center = buttonsFrame.center + } + + let titleSize = self.title.update( + transition: ComponentTransition(transition), + component: AnyComponent( + MultilineTextComponent( + text: .plain( + NSAttributedString( + string: self.presentationData.strings.Map_LocationTitle, + font: Font.semibold(17.0), + textColor: self.headerNode.mapNode.mapMode == .map ? self.presentationData.theme.rootController.navigationBar.primaryTextColor : .white + ) + ) + ) + ), + environment: {}, + containerSize: CGSize(width: 200.0, height: 40.0) + ) + let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - titleSize.width) / 2.0), y: floorToScreenPixels((navigationHeight - titleSize.height) / 2.0) + 3.0), size: titleSize) + if let titleView = self.title.view { + if titleView.superview == nil { + self.view.addSubview(titleView) + } + transition.updateFrame(view: titleView, frame: titleFrame) + } + } if isFirstLayout { while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } } - - let optionsOffset: CGFloat = self.state.displayingMapModeOptions ? navigationHeight : navigationHeight - optionsHeight - let optionsFrame = CGRect(x: 0.0, y: optionsOffset, width: layout.size.width, height: optionsHeight) - transition.updateFrame(node: self.optionsNode, frame: optionsFrame) - self.optionsNode.updateLayout(size: optionsFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, transition: transition) - self.optionsNode.isUserInteractionEnabled = self.state.displayingMapModeOptions } var coordinate: Signal { diff --git a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift index 12a60b459f..62e456fc28 100644 --- a/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift +++ b/submodules/MediaPickerUI/Sources/MediaPickerScreen.swift @@ -240,8 +240,6 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att private let cancelButtonNode: WebAppCancelButtonNode private var buttons: ComponentView? - private var cancelButton: ComponentView? - private var rightButton: ComponentView? private let moreButtonPlayOnce = ActionSlot() private let moreButtonNode: MoreButtonNode @@ -2104,7 +2102,6 @@ public final class MediaPickerScreenImpl: ViewController, MediaPickerScreen, Att } if case .glass = style { - self.cancelButton = ComponentView() self.buttons = ComponentView() } self.cancelButtonNode = WebAppCancelButtonNode(theme: self.presentationData.theme, strings: self.presentationData.strings) diff --git a/submodules/MimeTypes/Sources/TGMimeTypeMap.m b/submodules/MimeTypes/Sources/TGMimeTypeMap.m index 76d7fe9f65..894c12aa66 100644 --- a/submodules/MimeTypes/Sources/TGMimeTypeMap.m +++ b/submodules/MimeTypes/Sources/TGMimeTypeMap.m @@ -250,6 +250,7 @@ static void initializeMapping() mimeToExtension[@"text/plain"] = @"text"; extensionToMime[@"text"] = @"text/plain"; mimeToExtension[@"text/plain"] = @"diff"; extensionToMime[@"diff"] = @"text/plain"; mimeToExtension[@"text/plain"] = @"po"; extensionToMime[@"po"] = @"text/plain"; // reserve "pot" for vnd.ms-powerpoint + mimeToExtension[@"text/markdown"] = @"md"; extensionToMime[@"md"] = @"text/markdown"; mimeToExtension[@"text/richtext"] = @"rtx"; extensionToMime[@"rtx"] = @"text/richtext"; mimeToExtension[@"text/rtf"] = @"rtf"; extensionToMime[@"rtf"] = @"text/rtf"; mimeToExtension[@"text/texmacs"] = @"ts"; extensionToMime[@"ts"] = @"text/texmacs"; diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index 63caa2459c..7f82934154 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -373,7 +373,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { self.contentOffsetUpdated = f } - private func calculateMetrics(size: CGSize, additionalBottomInset: CGFloat) -> (topInset: CGFloat, itemWidth: CGFloat) { + private func calculateMetrics(size: CGSize, additionalBottomInset: CGFloat, isEmbedded: Bool) -> (topInset: CGFloat, itemWidth: CGFloat) { let itemCount = self.entries.count let itemInsets = UIEdgeInsets(top: 0.0, left: 12.0, bottom: 0.0, right: 12.0) @@ -394,7 +394,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } let initiallyRevealedRowCount = min(minimallyRevealedRowCount, CGFloat(rowCount)) - let gridTopInset = max(0.0, size.height - floor(initiallyRevealedRowCount * itemWidth) - 14.0 - additionalBottomInset) + let gridTopInset = isEmbedded ? 136.0 : max(0.0, size.height - floor(initiallyRevealedRowCount * itemWidth) - 14.0 - additionalBottomInset) return (gridTopInset, itemWidth) } @@ -569,10 +569,15 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } } + var isEmbedded = false func updateLayout(size: CGSize, isLandscape: Bool, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) { let firstLayout = self.validLayout == nil self.validLayout = (size, bottomInset) + self.contentTitleNode.isHidden = self.isEmbedded + self.contentSubtitleNode.isHidden = self.isEmbedded + self.searchButtonNode.isHidden = self.isEmbedded + let gridLayoutTransition: ContainedViewLayoutTransition if firstLayout { gridLayoutTransition = .immediate @@ -582,7 +587,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { self.overrideGridOffsetTransition = nil } - let (gridTopInset, itemWidth) = self.calculateMetrics(size: size, additionalBottomInset: bottomInset) + let (gridTopInset, itemWidth) = self.calculateMetrics(size: size, additionalBottomInset: bottomInset, isEmbedded: self.isEmbedded) var scrollToItem: GridNodeScrollToItem? if let ensurePeerVisibleOnLayout = self.ensurePeerVisibleOnLayout { @@ -680,8 +685,8 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { self.contentTitleNode.isHidden = true self.contentSubtitleNode.isHidden = true } else { - self.contentTitleNode.isHidden = false - self.contentSubtitleNode.isHidden = false + self.contentTitleNode.isHidden = self.isEmbedded + self.contentSubtitleNode.isHidden = self.isEmbedded var subtitleText = self.strings.ShareMenu_SelectChats if !self.controllerInteraction.selectedPeers.isEmpty { diff --git a/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift b/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift index e7709d56fc..af2fc0010a 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGridScrollingArea.swift @@ -1413,7 +1413,7 @@ public final class SparseItemGridScrollingArea: ASDisplayNode { } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if self.dateIndicator.alpha <= 0.01 { + if self.dateIndicator.alpha <= 0.01 || !self.isDateIndicatorVisible { return nil } if self.dateIndicator.frame.offsetBy(dx: self.dateIndicatorContainer.frame.minX, dy: self.dateIndicatorContainer.frame.minY).contains(point) { diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift index 51173eea52..b1f6611745 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourceKey.swift @@ -132,6 +132,12 @@ public enum PresentationResourceKey: Int32 { case chatListGiftIcon case chatListLocationIcon case chatListPollIcon + case chatListTodoIcon + case chatListGameIcon + case chatListCallIncomingIcon + case chatListCallOutgoingIcon + case chatListCallVideoIncomingIcon + case chatListCallVideoOutgoingIcon case chatListGeneralTopicIcon case chatListGeneralTopicTemplateIcon diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift index c0c7a791c6..2092f8b75b 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesChatList.swift @@ -313,6 +313,42 @@ public struct PresentationResourcesChatList { }) } + public static func todoIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListTodoIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/TodoIcon"), color: theme.chatList.muteIconColor) + }) + } + + public static func gameIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListGameIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/GameIcon"), color: theme.chatList.muteIconColor) + }) + } + + public static func callIncomingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListCallIncomingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/CallIncomingIcon"), color: theme.chatList.muteIconColor) + }) + } + + public static func callOutgoingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListCallOutgoingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/CallOutgoingIcon"), color: theme.chatList.muteIconColor) + }) + } + + public static func callVideoIncomingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListCallVideoIncomingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/CallVideoIncomingIcon"), color: theme.chatList.muteIconColor) + }) + } + + public static func callVideoOutgoingIcon(_ theme: PresentationTheme) -> UIImage? { + return theme.image(PresentationResourceKey.chatListCallVideoOutgoingIcon.rawValue, { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat List/CallVideoOutgoingIcon"), color: theme.chatList.muteIconColor) + }) + } + public static func verifiedIcon(_ theme: PresentationTheme) -> UIImage? { return theme.image(PresentationResourceKey.chatListVerifiedIcon.rawValue, { theme in if let backgroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconBackground"), let foregroundImage = UIImage(bundleImageName: "Chat List/PeerVerifiedIconForeground") { diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 764a33bcce..59680b35d0 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -526,6 +526,8 @@ swift_library( "//submodules/TelegramUI/Components/RankChatPreviewItem", "//submodules/TelegramUI/Components/TextProcessingScreen", "//submodules/TelegramUI/Components/CreateBotScreen", + "//submodules/TelegramUI/Components/ShareScreen", + "//submodules/Utils/AutomationBridge", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD b/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD index fd2f6c708b..c2e9d97ddd 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/BUILD @@ -17,6 +17,7 @@ swift_library( "//submodules/ComponentFlow", "//submodules/Components/ViewControllerComponent", "//submodules/Components/ComponentDisplayAdapters", + "//submodules/Components/ResizableSheetComponent", "//submodules/Components/MultilineTextComponent", "//submodules/Components/BundleIconComponent", "//submodules/TelegramPresentationData", diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift index dc1f55617c..f89ecc8d9f 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsPeerComponent.swift @@ -227,7 +227,7 @@ final class AdminUserActionsPeerComponent: Component { let titleSize = self.title.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: component.title, font: Font.semibold(component.baseFontSize), textColor: component.theme.list.itemPrimaryTextColor)) + text: .plain(NSAttributedString(string: component.title, font: Font.medium(component.baseFontSize), textColor: component.theme.list.itemPrimaryTextColor)) )), environment: {}, containerSize: CGSize(width: maxTextSize, height: 100.0) diff --git a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift index 54d828be17..636318a5c4 100644 --- a/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift +++ b/submodules/TelegramUI/Components/AdminUserActionsSheet/Sources/AdminUserActionsSheet.swift @@ -1,24 +1,21 @@ import Foundation import UIKit import Display -import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent -import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore import MultilineTextComponent import ButtonComponent import PresentationDataUtils -import Markdown -import UndoUI -import AvatarNode -import TelegramStringFormatting import ListSectionComponent import ListActionItemComponent import PlainButtonComponent +import ResizableSheetComponent +import GlassBarButtonComponent +import BundleIconComponent struct MediaRight: OptionSet, Hashable { var rawValue: Int @@ -187,6 +184,768 @@ private let allMediaRightItems: [MediaRight] = [ .polls ] +private enum AdminUserActionOptionSection { + case report + case deleteAll + case ban +} + +private enum AdminUserActionConfigItem: Hashable, CaseIterable { + case sendMessages + case sendMedia + case addUsers + case pinMessages + case changeInfo +} + +private struct AdminUserActionsSheetState: Equatable { + var isOptionReportExpanded: Bool + var optionReportSelectedPeers: Set + var isOptionDeleteAllExpanded: Bool + var optionDeleteAllSelectedPeers: Set + var optionDeleteAllReactionsSelectedPeers: Set + var isOptionBanExpanded: Bool + var optionBanSelectedPeers: Set + var isConfigurationExpanded: Bool + var isMediaSectionExpanded: Bool + var allowedParticipantRights: ParticipantRight + var allowedMediaRights: MediaRight + var participantRights: ParticipantRight + var mediaRights: MediaRight +} + +private func availableAdminUserActionOptionSections( + accountPeerId: EnginePeer.Id, + chatPeer: EnginePeer, + peers: [RenderedChannelParticipant], + mode: AdminUserActionsSheet.Mode +) -> [AdminUserActionOptionSection] { + var result: [AdminUserActionOptionSection] = [.report] + + switch mode { + case .monoforum: + result.append(.ban) + case .chat, .chatReaction: + if case let .channel(channel) = chatPeer { + if channel.hasPermission(.deleteAllMessages) { + result.append(.deleteAll) + + if channel.hasPermission(.banMembers) { + var canBanEveryone = true + for peer in peers { + if peer.peer.id == accountPeerId { + canBanEveryone = false + continue + } + + switch peer.participant { + case .creator: + canBanEveryone = false + case let .member(_, _, adminInfo, _, _, _): + if let adminInfo { + if channel.flags.contains(.isCreator) { + } else if adminInfo.promotedBy == accountPeerId { + } else { + canBanEveryone = false + } + } + } + } + + if canBanEveryone { + result.append(.ban) + } + } + } + } + case .liveStream: + result.append(.deleteAll) + result.append(.ban) + } + + return result +} + +private func adminUserActionsTitle( + strings: PresentationStrings, + mode: AdminUserActionsSheet.Mode, + peers: [RenderedChannelParticipant], + selectedDeleteAllPeers: Set +) -> String { + switch mode { + case .monoforum: + if let peer = peers.first { + return strings.Monoforum_DeleteTopic_Title(EnginePeer(peer.peer).compactDisplayTitle).string + } else { + return strings.Common_Delete + } + case let .chat(messageCount, deleteAllMessageCount, _): + if let deleteAllMessageCount, selectedDeleteAllPeers == Set(peers.map { $0.peer.id }) { + return strings.Chat_AdminActionSheet_DeleteTitle(Int32(deleteAllMessageCount)) + } else { + return strings.Chat_AdminActionSheet_DeleteTitle(Int32(messageCount)) + } + case let .liveStream(messageCount, deleteAllMessageCount, _): + if let deleteAllMessageCount, selectedDeleteAllPeers == Set(peers.map { $0.peer.id }) { + return strings.Chat_AdminActionSheet_DeleteTitle(Int32(deleteAllMessageCount)) + } else { + return strings.Chat_AdminActionSheet_DeleteTitle(Int32(messageCount)) + } + case .chatReaction(_): + //TODO:localize + return "Delete 1 Reaction" + } +} + +private final class AdminUserActionsContentComponent: Component { + typealias EnvironmentType = ViewControllerComponentContainer.Environment + + let context: AccountContext + let chatPeer: EnginePeer + let peers: [RenderedChannelParticipant] + let mode: AdminUserActionsSheet.Mode + let theme: PresentationTheme + let strings: PresentationStrings + let presentationData: PresentationData + let sheetState: AdminUserActionsSheetState + let disableOptionsSectionAnimation: Bool + let toggleOptionSelection: (AdminUserActionOptionSection) -> Void + let toggleOptionExpansion: (AdminUserActionOptionSection) -> Void + let togglePeerSelection: (AdminUserActionOptionSection, EnginePeer) -> Void + let toggleConfiguration: () -> Void + let toggleConfigItem: (AdminUserActionConfigItem) -> Void + let toggleMediaSectionExpansion: () -> Void + let toggleMediaRight: (MediaRight) -> Void + + init( + context: AccountContext, + chatPeer: EnginePeer, + peers: [RenderedChannelParticipant], + mode: AdminUserActionsSheet.Mode, + theme: PresentationTheme, + strings: PresentationStrings, + presentationData: PresentationData, + sheetState: AdminUserActionsSheetState, + disableOptionsSectionAnimation: Bool, + toggleOptionSelection: @escaping (AdminUserActionOptionSection) -> Void, + toggleOptionExpansion: @escaping (AdminUserActionOptionSection) -> Void, + togglePeerSelection: @escaping (AdminUserActionOptionSection, EnginePeer) -> Void, + toggleConfiguration: @escaping () -> Void, + toggleConfigItem: @escaping (AdminUserActionConfigItem) -> Void, + toggleMediaSectionExpansion: @escaping () -> Void, + toggleMediaRight: @escaping (MediaRight) -> Void + ) { + self.context = context + self.chatPeer = chatPeer + self.peers = peers + self.mode = mode + self.theme = theme + self.strings = strings + self.presentationData = presentationData + self.sheetState = sheetState + self.disableOptionsSectionAnimation = disableOptionsSectionAnimation + self.toggleOptionSelection = toggleOptionSelection + self.toggleOptionExpansion = toggleOptionExpansion + self.togglePeerSelection = togglePeerSelection + self.toggleConfiguration = toggleConfiguration + self.toggleConfigItem = toggleConfigItem + self.toggleMediaSectionExpansion = toggleMediaSectionExpansion + self.toggleMediaRight = toggleMediaRight + } + + static func ==(lhs: AdminUserActionsContentComponent, rhs: AdminUserActionsContentComponent) -> Bool { + return false + } + + final class View: UIView { + private let optionsSection = ComponentView() + private let optionsFooter = ComponentView() + private let configSection = ComponentView() + + func update(component: AdminUserActionsContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let environment = environment[ViewControllerComponentContainer.Environment.self].value + let sideInset: CGFloat = 16.0 + var contentHeight: CGFloat = 76.0 + + contentHeight += 15.0 + + let availableOptions = availableAdminUserActionOptionSections( + accountPeerId: component.context.account.peerId, + chatPeer: component.chatPeer, + peers: component.peers, + mode: component.mode + ) + + let optionsItem: (AdminUserActionOptionSection) -> AnyComponentWithIdentity = { section in + let sectionId: AnyHashable + let selectedPeers: Set + var additionalSelectedPeers = Set() + let isExpanded: Bool + let title: String + + switch section { + case .report: + sectionId = "report" + selectedPeers = component.sheetState.optionReportSelectedPeers + isExpanded = component.sheetState.isOptionReportExpanded + title = component.strings.Chat_AdminActionSheet_ReportSpam + case .deleteAll: + sectionId = "delete-all" + selectedPeers = component.sheetState.optionDeleteAllSelectedPeers + additionalSelectedPeers = component.sheetState.optionDeleteAllReactionsSelectedPeers + isExpanded = component.sheetState.isOptionDeleteAllExpanded + if component.peers.count == 1 { + title = component.strings.Chat_AdminActionSheet_DeleteAllSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string + } else { + title = component.strings.Chat_AdminActionSheet_DeleteAllMultiple + } + case .ban: + sectionId = "ban" + selectedPeers = component.sheetState.optionBanSelectedPeers + isExpanded = component.sheetState.isOptionBanExpanded + + let banTitle: String + let restrictTitle: String + if component.peers.count == 1 { + banTitle = component.strings.Chat_AdminActionSheet_BanSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string + restrictTitle = component.strings.Chat_AdminActionSheet_RestrictSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string + } else { + banTitle = component.strings.Chat_AdminActionSheet_BanMultiple + restrictTitle = component.strings.Chat_AdminActionSheet_RestrictMultiple + } + title = component.sheetState.isConfigurationExpanded ? restrictTitle : banTitle + } + + var titleItems: [AnyComponentWithIdentity] = [ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: title, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))) + ] + + var accessory: ListActionItemComponent.Accessory? + var isExpandable = false + if component.peers.count > 1 { + accessory = .custom(ListActionItemComponent.CustomAccessory( + component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( + content: AnyComponent(OptionSectionExpandIndicatorComponent( + theme: component.theme, + count: selectedPeers.isEmpty ? component.peers.count : selectedPeers.count, + isExpanded: isExpanded + )), + effectAlignment: .center, + action: { + component.toggleOptionExpansion(section) + }, + animateScale: false + ))), + insets: UIEdgeInsets(top: 0.0, left: 6.0, bottom: 0.0, right: 2.0), + isInteractive: true + )) + } else if case .deleteAll = section { + var count = 0 + if !selectedPeers.isEmpty { + count += 1 + } + if !additionalSelectedPeers.isEmpty { + count += 1 + } + titleItems.append( + AnyComponentWithIdentity(id: 1, component: AnyComponent(MediaSectionExpandIndicatorComponent( + theme: component.theme, + title: "\(count)/2", + isExpanded: component.sheetState.isMediaSectionExpanded + ))) + ) + isExpandable = true + } + + return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListActionItemComponent( + theme: component.theme, + style: .glass, + title: AnyComponent(HStack(titleItems, spacing: 7.0)), + leftIcon: .check(ListActionItemComponent.LeftIcon.Check( + isSelected: !selectedPeers.isEmpty || !additionalSelectedPeers.isEmpty, + toggle: { + component.toggleOptionSelection(section) + } + )), + icon: .none, + accessory: accessory, + action: { _ in + if isExpandable { + component.toggleOptionExpansion(section) + } else { + component.toggleOptionSelection(section) + } + }, + highlighting: .disabled + ))) + } + + let expandedPeersItem: (AdminUserActionOptionSection) -> AnyComponentWithIdentity = { section in + let sectionId: AnyHashable + let selectedPeers: Set + var additionalSelectedPeers = Set() + switch section { + case .report: + sectionId = "report-peers" + selectedPeers = component.sheetState.optionReportSelectedPeers + case .deleteAll: + sectionId = "delete-all-peers" + selectedPeers = component.sheetState.optionDeleteAllSelectedPeers + additionalSelectedPeers = component.sheetState.optionDeleteAllReactionsSelectedPeers + case .ban: + sectionId = "ban-peers" + selectedPeers = component.sheetState.optionBanSelectedPeers + } + + var subItems: [AnyComponentWithIdentity] = [] + if component.peers.count > 1 { + for peer in component.peers { + subItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(AdminUserActionsPeerComponent( + context: component.context, + theme: component.theme, + strings: component.strings, + baseFontSize: component.presentationData.listsFontSize.baseDisplaySize, + sideInset: 0.0, + title: EnginePeer(peer.peer).displayTitle(strings: component.strings, displayOrder: .firstLast), + peer: EnginePeer(peer.peer), + selectionState: .editing(isSelected: selectedPeers.contains(peer.peer.id)), + action: { peer in + component.togglePeerSelection(section, peer) + } + )))) + } + } else { + //TODO:localize + subItems.append( + AnyComponentWithIdentity(id: 0, component: AnyComponent(ListActionItemComponent( + theme: component.theme, + style: .glass, + title: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Delete all Messages", + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )), + leftIcon: .check(ListActionItemComponent.LeftIcon.Check( + isSelected: !selectedPeers.isEmpty, + toggle: { + component.toggleOptionSelection(section) + } + )), + icon: .none, + accessory: nil, + action: { _ in + component.toggleOptionSelection(section) + }, + highlighting: .disabled + ))) + ) + subItems.append( + AnyComponentWithIdentity(id: 1, component: AnyComponent(ListActionItemComponent( + theme: component.theme, + style: .glass, + title: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: "Delete all Reactions", + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )), + leftIcon: .check(ListActionItemComponent.LeftIcon.Check( + isSelected: !additionalSelectedPeers.isEmpty, + toggle: { + component.toggleOptionSelection(section) + } + )), + icon: .none, + accessory: nil, + action: { _ in + component.toggleOptionSelection(section) + }, + highlighting: .disabled + ))) + ) + } + + return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListSubSectionComponent( + theme: component.theme, + leftInset: 43.0, + items: subItems + ))) + } + + var optionsSectionItems: [AnyComponentWithIdentity] = [] + for option in availableOptions { + let isExpanded: Bool + switch option { + case .report: + isExpanded = component.sheetState.isOptionReportExpanded + case .deleteAll: + isExpanded = component.sheetState.isOptionDeleteAllExpanded + case .ban: + isExpanded = component.sheetState.isOptionBanExpanded + } + + optionsSectionItems.append(optionsItem(option)) + if isExpanded { + optionsSectionItems.append(expandedPeersItem(option)) + } + } + + let optionsSectionTransition: ComponentTransition = component.disableOptionsSectionAnimation ? transition.withAnimation(.none) : transition + let optionsSectionSize = self.optionsSection.update( + transition: optionsSectionTransition, + component: AnyComponent(ListSectionComponent( + theme: component.theme, + style: .glass, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Chat_AdminActionSheet_RestrictSectionHeader, + font: Font.regular(component.presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: component.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: optionsSectionItems, + isModal: true + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) + ) + let optionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: optionsSectionSize) + self.optionsSection.parentState = state + if let optionsSectionView = self.optionsSection.view { + if optionsSectionView.superview == nil { + self.addSubview(optionsSectionView) + } + transition.setFrame(view: optionsSectionView, frame: optionsSectionFrame) + } + contentHeight += optionsSectionSize.height + + let partiallyRestrictTitle: String + let fullyBanTitle: String + if component.peers.count == 1 { + partiallyRestrictTitle = component.strings.Chat_AdminActionSheet_RestrictFooterSingle + fullyBanTitle = component.strings.Chat_AdminActionSheet_BanFooterSingle + } else { + partiallyRestrictTitle = component.strings.Chat_AdminActionSheet_RestrictFooterMultiple + fullyBanTitle = component.strings.Chat_AdminActionSheet_BanFooterMultiple + } + + let optionsFooterSize = self.optionsFooter.update( + transition: transition, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(OptionsSectionFooterComponent( + theme: component.theme, + text: component.sheetState.isConfigurationExpanded ? fullyBanTitle : partiallyRestrictTitle, + fontSize: component.presentationData.listsFontSize.itemListBaseHeaderFontSize, + isExpanded: component.sheetState.isConfigurationExpanded + )), + effectAlignment: .left, + contentInsets: UIEdgeInsets(), + action: { + component.toggleConfiguration() + }, + animateAlpha: true, + animateScale: false, + animateContents: true + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) + ) + + var configSectionItems: [AnyComponentWithIdentity] = [] + + if case let .channel(channel) = component.chatPeer, channel.isMonoForum { + } else if case .liveStream = component.mode { + } else { + var allConfigItems: [(AdminUserActionConfigItem, Bool)] = [] + if !component.sheetState.allowedMediaRights.isEmpty || !component.sheetState.allowedParticipantRights.isEmpty { + for configItem in AdminUserActionConfigItem.allCases { + let isEnabled: Bool + switch configItem { + case .sendMessages: + isEnabled = component.sheetState.allowedParticipantRights.contains(.sendMessages) + case .sendMedia: + isEnabled = !component.sheetState.allowedMediaRights.isEmpty + case .addUsers: + isEnabled = component.sheetState.allowedParticipantRights.contains(.addMembers) + case .pinMessages: + isEnabled = component.sheetState.allowedParticipantRights.contains(.pinMessages) + case .changeInfo: + isEnabled = component.sheetState.allowedParticipantRights.contains(.changeInfo) + } + allConfigItems.append((configItem, isEnabled)) + } + } + + for (configItem, isEnabled) in allConfigItems { + let itemTitle: AnyComponent + let itemValue: Bool + switch configItem { + case .sendMessages: + itemTitle = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_BanUser_PermissionSendMessages, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )) + itemValue = component.sheetState.participantRights.contains(.sendMessages) + case .sendMedia: + if isEnabled { + itemTitle = AnyComponent(HStack([ + AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_BanUser_PermissionSendMedia, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))), + AnyComponentWithIdentity(id: 1, component: AnyComponent(MediaSectionExpandIndicatorComponent( + theme: component.theme, + title: "\(component.sheetState.mediaRights.count)/\(component.sheetState.allowedMediaRights.count)", + isExpanded: component.sheetState.isMediaSectionExpanded + ))) + ], spacing: 7.0)) + } else { + itemTitle = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_BanUser_PermissionSendMedia, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )) + } + itemValue = !component.sheetState.mediaRights.isEmpty + case .addUsers: + itemTitle = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_BanUser_PermissionAddMembers, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )) + itemValue = component.sheetState.participantRights.contains(.addMembers) + case .pinMessages: + itemTitle = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_EditAdmin_PermissionPinMessages, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )) + itemValue = component.sheetState.participantRights.contains(.pinMessages) + case .changeInfo: + itemTitle = AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.strings.Channel_BanUser_PermissionChangeGroupInfo, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )) + itemValue = component.sheetState.participantRights.contains(.changeInfo) + } + + configSectionItems.append(AnyComponentWithIdentity(id: configItem, component: AnyComponent(ListActionItemComponent( + theme: component.theme, + style: .glass, + title: itemTitle, + accessory: .toggle(ListActionItemComponent.Toggle( + style: isEnabled ? .icons : .lock, + isOn: itemValue, + isInteractive: isEnabled, + action: isEnabled ? { _ in + component.toggleConfigItem(configItem) + } : nil + )), + action: ((isEnabled && configItem == .sendMedia) || !isEnabled) ? { _ in + if !isEnabled { + environment.controller()?.present(textAlertController( + context: component.context, + title: nil, + text: component.strings.GroupPermission_PermissionDisabledByDefault, + actions: [ + TextAlertAction(type: .defaultAction, title: component.strings.Common_OK, action: { + }) + ] + ), in: .window(.root)) + } else { + component.toggleMediaSectionExpansion() + } + } : nil, + highlighting: .disabled + )))) + + if isEnabled, case .sendMedia = configItem, component.sheetState.isMediaSectionExpanded { + var mediaItems: [AnyComponentWithIdentity] = [] + mediaRightsLoop: for possibleMediaItem in allMediaRightItems { + if !component.sheetState.allowedMediaRights.contains(possibleMediaItem) { + continue + } + + let mediaItemTitle: String + switch possibleMediaItem { + case .photos: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendPhoto + case .videos: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendVideo + case .stickersAndGifs: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendStickersAndGifs + case .music: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendMusic + case .files: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendFile + case .voiceMessages: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendVoiceMessage + case .videoMessages: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendVideoMessage + case .links: + mediaItemTitle = component.strings.Channel_BanUser_PermissionEmbedLinks + case .polls: + mediaItemTitle = component.strings.Channel_BanUser_PermissionSendPolls + default: + continue mediaRightsLoop + } + + mediaItems.append(AnyComponentWithIdentity(id: possibleMediaItem, component: AnyComponent(ListActionItemComponent( + theme: component.theme, + style: .glass, + title: AnyComponent(VStack([ + AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: mediaItemTitle, + font: Font.regular(component.presentationData.listsFontSize.baseDisplaySize), + textColor: component.theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + ))) + ], alignment: .left, spacing: 2.0)), + leftIcon: .check(ListActionItemComponent.LeftIcon.Check( + isSelected: component.sheetState.mediaRights.contains(possibleMediaItem), + toggle: { + component.toggleMediaRight(possibleMediaItem) + } + )), + icon: .none, + accessory: .none, + action: { _ in + component.toggleMediaRight(possibleMediaItem) + }, + highlighting: .disabled + )))) + } + configSectionItems.append(AnyComponentWithIdentity(id: "media-sub", component: AnyComponent(ListSubSectionComponent( + theme: component.theme, + leftInset: 0.0, + items: mediaItems + )))) + } + } + } + + let configSectionSize = self.configSection.update( + transition: transition, + component: AnyComponent(ListSectionComponent( + theme: component.theme, + style: .glass, + header: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: component.peers.count == 1 ? component.strings.Chat_AdminActionSheet_PermissionsSectionHeader : component.strings.Chat_AdminActionSheet_PermissionsSectionHeaderMultiple, + font: Font.regular(component.presentationData.listsFontSize.itemListBaseHeaderFontSize), + textColor: component.theme.list.freeTextColor + )), + maximumNumberOfLines: 0 + )), + footer: nil, + items: configSectionItems + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) + ) + let configSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 30.0), size: configSectionSize) + self.configSection.parentState = state + if let configSectionView = self.configSection.view { + if configSectionView.superview == nil { + configSectionView.clipsToBounds = true + configSectionView.layer.cornerRadius = 11.0 + self.addSubview(configSectionView) + } + let effectiveConfigSectionFrame: CGRect + if component.sheetState.isConfigurationExpanded { + effectiveConfigSectionFrame = configSectionFrame + } else { + effectiveConfigSectionFrame = CGRect(origin: CGPoint(x: configSectionFrame.minX, y: configSectionFrame.minY - 30.0), size: CGSize(width: configSectionFrame.width, height: 0.0)) + } + transition.setFrame(view: configSectionView, frame: effectiveConfigSectionFrame) + transition.setAlpha(view: configSectionView, alpha: component.sheetState.isConfigurationExpanded ? 1.0 : 0.0) + } + + if availableOptions.contains(.ban) && !configSectionItems.isEmpty { + let optionsFooterFrame: CGRect + if component.sheetState.isConfigurationExpanded { + contentHeight += 30.0 + contentHeight += configSectionSize.height + contentHeight += 7.0 + optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) + contentHeight += optionsFooterSize.height + } else { + contentHeight += 7.0 + optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) + contentHeight += optionsFooterSize.height + } + self.optionsFooter.parentState = state + if let optionsFooterView = self.optionsFooter.view { + if optionsFooterView.superview == nil { + self.addSubview(optionsFooterView) + } + transition.setFrame(view: optionsFooterView, frame: optionsFooterFrame) + transition.setAlpha(view: optionsFooterView, alpha: 1.0) + } + } else { + self.optionsFooter.parentState = state + if let optionsFooterView = self.optionsFooter.view { + if optionsFooterView.superview == nil { + self.addSubview(optionsFooterView) + } + let optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) + transition.setFrame(view: optionsFooterView, frame: optionsFooterFrame) + transition.setAlpha(view: optionsFooterView, alpha: 0.0) + } + } + + contentHeight += 36.0 + 52.0 + 30.0 + + return CGSize(width: availableSize.width, height: contentHeight) + } + } + + 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 AdminUserActionsSheetComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment @@ -211,57 +970,15 @@ private final class AdminUserActionsSheetComponent: Component { return true } - private struct ItemLayout: Equatable { - var containerSize: CGSize - var containerInset: CGFloat - var bottomInset: CGFloat - var topInset: CGFloat - - init(containerSize: CGSize, containerInset: CGFloat, bottomInset: CGFloat, topInset: CGFloat) { - self.containerSize = containerSize - self.containerInset = containerInset - self.bottomInset = bottomInset - self.topInset = topInset - } - } - - private final class ScrollView: UIScrollView { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) - } - } - - final class View: UIView, UIScrollViewDelegate { - private let dimView: UIView - private let backgroundLayer: SimpleLayer - private let navigationBarContainer: SparseContainerView - private let navigationBackgroundView: BlurredBackgroundView - private let navigationBarSeparator: SimpleLayer - private let scrollView: ScrollView - private let scrollContentClippingView: SparseContainerView - private let scrollContentView: UIView - - private let leftButton = ComponentView() - - private let title = ComponentView() - private let actionButton = ComponentView() - - private let optionsSection = ComponentView() - private let optionsFooter = ComponentView() - private let configSection = ComponentView() - - private let bottomOverscrollLimit: CGFloat - - private var ignoreScrolling: Bool = false + final class View: UIView { + private let sheet = ComponentView<(ViewControllerComponentContainer.Environment, ResizableSheetComponentEnvironment)>() + private let animateOut = ActionSlot>() private var component: AdminUserActionsSheetComponent? private weak var state: EmptyComponentState? private var environment: ViewControllerComponentContainer.Environment? private var isUpdating: Bool = false - - private var itemLayout: ItemLayout? - - private var topOffsetDistance: CGFloat? + private var isDismissing: Bool = false private var isOptionReportExpanded: Bool = false private var optionReportSelectedPeers = Set() @@ -281,110 +998,13 @@ private final class AdminUserActionsSheetComponent: Component { private var previousWasConfigurationExpanded: Bool = false override init(frame: CGRect) { - self.bottomOverscrollLimit = 200.0 - - self.dimView = UIView() - - self.backgroundLayer = SimpleLayer() - self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - self.backgroundLayer.cornerRadius = 10.0 - - self.navigationBarContainer = SparseContainerView() - - self.navigationBackgroundView = BlurredBackgroundView(color: .clear, enableBlur: true) - self.navigationBarSeparator = SimpleLayer() - - self.scrollView = ScrollView() - - self.scrollContentClippingView = SparseContainerView() - self.scrollContentClippingView.clipsToBounds = true - - self.scrollContentView = UIView() - super.init(frame: frame) - - self.addSubview(self.dimView) - self.layer.addSublayer(self.backgroundLayer) - - self.scrollView.delaysContentTouches = true - self.scrollView.canCancelContentTouches = true - self.scrollView.clipsToBounds = false - if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { - self.scrollView.contentInsetAdjustmentBehavior = .never - } - if #available(iOS 13.0, *) { - self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - } - self.scrollView.showsVerticalScrollIndicator = false - self.scrollView.showsHorizontalScrollIndicator = false - self.scrollView.alwaysBounceHorizontal = false - self.scrollView.alwaysBounceVertical = true - self.scrollView.scrollsToTop = false - self.scrollView.delegate = self - self.scrollView.clipsToBounds = true - - self.addSubview(self.scrollContentClippingView) - self.scrollContentClippingView.addSubview(self.scrollView) - - self.scrollView.addSubview(self.scrollContentView) - - self.addSubview(self.navigationBarContainer) - - self.navigationBarContainer.addSubview(self.navigationBackgroundView) - self.navigationBarContainer.layer.addSublayer(self.navigationBarSeparator) - - self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if !self.ignoreScrolling { - self.updateScrolling(transition: .immediate) - } - } - - func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - /*guard let itemLayout = self.itemLayout, let topOffsetDistance = self.topOffsetDistance else { - return - } - - var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset - topOffset = max(0.0, topOffset) - - if topOffset < topOffsetDistance { - targetContentOffset.pointee.y = scrollView.contentOffset.y - scrollView.setContentOffset(CGPoint(x: 0.0, y: itemLayout.topInset), animated: true) - }*/ - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - if !self.bounds.contains(point) { - return nil - } - if !self.backgroundLayer.frame.contains(point) { - return self.dimView - } - - if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) { - return result - } - - let result = super.hitTest(point, with: event) - return result - } - - @objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - guard let environment = self.environment, let controller = environment.controller() else { - return - } - controller.dismiss() - } - } - private func calculateMonoforumResult() -> AdminUserActionsSheet.MonoforumResult { return AdminUserActionsSheet.MonoforumResult( ban: !self.optionBanSelectedPeers.isEmpty, @@ -395,6 +1015,7 @@ private final class AdminUserActionsSheetComponent: Component { private func calculateChatResult() -> AdminUserActionsSheet.ChatResult { var reportSpamPeers: [EnginePeer.Id] = [] var deleteAllFromPeers: [EnginePeer.Id] = [] + var deleteAllReactionsFromPeers: [EnginePeer.Id] = [] var banPeers: [EnginePeer.Id] = [] var updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights] = [:] @@ -405,14 +1026,16 @@ private final class AdminUserActionsSheetComponent: Component { deleteAllFromPeers.append(id) } + for id in self.optionDeleteAllSelectedPeers.sorted() { + deleteAllReactionsFromPeers.append(id) + } + if !self.isConfigurationExpanded { for id in self.optionBanSelectedPeers.sorted() { banPeers.append(id) } } else { - var banFlags: TelegramChatBannedRightsFlags = [] - banFlags = rightFlagsFromRights(participantRights: self.participantRights, mediaRights: self.mediaRights) - + let banFlags = rightFlagsFromRights(participantRights: self.participantRights, mediaRights: self.mediaRights) let bannedRights = TelegramChatBannedRights(flags: banFlags, untilDate: Int32.max) for id in self.optionBanSelectedPeers.sorted() { updateBannedRights[id] = bannedRights @@ -422,12 +1045,13 @@ private final class AdminUserActionsSheetComponent: Component { return AdminUserActionsSheet.ChatResult( reportSpamPeers: reportSpamPeers, deleteAllFromPeers: deleteAllFromPeers, + deleteAllReactionsFromPeers: deleteAllReactionsFromPeers, banPeers: banPeers, updateBannedRights: updateBannedRights ) } - private func calculateLiveStreamResult() -> AdminUserActionsSheet.LiveStreamResult { + private func calculateLiveStreamResult() -> AdminUserActionsSheet.LiveStreamResult { return AdminUserActionsSheet.LiveStreamResult( reportSpam: !self.optionReportSelectedPeers.isEmpty, deleteAll: !self.optionDeleteAllSelectedPeers.isEmpty, @@ -435,85 +1059,16 @@ private final class AdminUserActionsSheetComponent: Component { ) } - private func updateScrolling(transition: ComponentTransition) { - guard let environment = self.environment, let controller = environment.controller(), let itemLayout = self.itemLayout else { - return - } - var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset - - let navigationAlpha: CGFloat = 1.0 - max(0.0, min(1.0, (topOffset + 20.0) / 20.0)) - transition.setAlpha(view: self.navigationBackgroundView, alpha: navigationAlpha) - transition.setAlpha(layer: self.navigationBarSeparator, alpha: navigationAlpha) - - topOffset = max(0.0, topOffset) - transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0)) - - transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset)) - - let topOffsetDistance: CGFloat = min(200.0, floor(itemLayout.containerSize.height * 0.25)) - self.topOffsetDistance = topOffsetDistance - var topOffsetFraction = topOffset / topOffsetDistance - topOffsetFraction = max(0.0, min(1.0, topOffsetFraction)) - - let transitionFactor: CGFloat = 1.0 - topOffsetFraction - if self.isUpdating { - DispatchQueue.main.async { [weak controller] in - guard let controller else { - return - } - controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) - } - } else { - controller.updateModalStyleOverlayTransitionFactor(transitionFactor, transition: transition.containedViewLayoutTransition) - } - } - - func animateIn() { - self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY - self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - if let actionButtonView = self.actionButton.view { - actionButtonView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) - } - } - - func animateOut(completion: @escaping () -> Void) { - let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY - - self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) - self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in - completion() - }) - self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) - self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) - if let actionButtonView = self.actionButton.view { - actionButtonView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true) - } - - if let environment = self.environment, let controller = environment.controller() { - controller.updateModalStyleOverlayTransitionFactor(0.0, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - } - func update(component: AdminUserActionsSheetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { self.isUpdating = false } - let environment = environment[ViewControllerComponentContainer.Environment.self].value - let themeUpdated = self.environment?.theme !== environment.theme - - let resetScrolling = self.scrollView.bounds.width != availableSize.width - - let sideInset: CGFloat = 16.0 + 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{ + guard let self else { return } @@ -582,188 +1137,90 @@ private final class AdminUserActionsSheetComponent: Component { self.component = component self.state = state - self.environment = environment - if themeUpdated { - self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5) - self.backgroundLayer.backgroundColor = environment.theme.actionSheet.opaqueItemBackgroundColor.cgColor - - self.navigationBackgroundView.updateColor(color: environment.theme.rootController.navigationBar.blurredBackgroundColor, transition: .immediate) - self.navigationBarSeparator.backgroundColor = environment.theme.rootController.navigationBar.separatorColor.cgColor - } + let environmentValue = environment[ViewControllerComponentContainer.Environment.self].value + self.environment = environmentValue + let controller = environmentValue.controller + let theme = environmentValue.theme.withModalBlocksBackground() let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) - transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize)) - - var contentHeight: CGFloat = 0.0 - contentHeight += 54.0 - contentHeight += 16.0 - - let leftButtonSize = self.leftButton.update( - transition: transition, - component: AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Cancel, font: Font.regular(17.0), color: environment.theme.list.itemAccentColor)), - action: { [weak self] in - guard let self, let controller = self.environment?.controller() else { - return - } - controller.dismiss() + let dismiss: (Bool) -> Void = { [weak self] animated in + guard let self, !self.isDismissing else { + return + } + self.isDismissing = true + + let performDismiss: () -> Void = { + if let controller = controller() as? AdminUserActionsSheet { + controller.completePendingDismiss() + controller.dismiss(animated: false) } - ).minSize(CGSize(width: 44.0, height: 56.0))), - environment: {}, - containerSize: CGSize(width: 120.0, height: 100.0) + } + + if animated { + self.animateOut.invoke(Action { _ in + performDismiss() + }) + } else { + performDismiss() + } + } + + let disableOptionsSectionAnimation = self.previousWasConfigurationExpanded != self.isConfigurationExpanded + self.previousWasConfigurationExpanded = self.isConfigurationExpanded + + let currentState = AdminUserActionsSheetState( + isOptionReportExpanded: self.isOptionReportExpanded, + optionReportSelectedPeers: self.optionReportSelectedPeers, + isOptionDeleteAllExpanded: self.isOptionDeleteAllExpanded, + optionDeleteAllSelectedPeers: self.optionDeleteAllSelectedPeers, + optionDeleteAllReactionsSelectedPeers: self.optionDeleteAllSelectedPeers, + isOptionBanExpanded: self.isOptionBanExpanded, + optionBanSelectedPeers: self.optionBanSelectedPeers, + isConfigurationExpanded: self.isConfigurationExpanded, + isMediaSectionExpanded: self.isMediaSectionExpanded, + allowedParticipantRights: self.allowedParticipantRights, + allowedMediaRights: self.allowedMediaRights, + participantRights: self.participantRights, + mediaRights: self.mediaRights ) - let leftButtonFrame = CGRect(origin: CGPoint(x: 16.0 + environment.safeInsets.left, y: 0.0), size: leftButtonSize) - if let leftButtonView = self.leftButton.view { - if leftButtonView.superview == nil { - self.navigationBarContainer.addSubview(leftButtonView) + + let performMainAction: () -> Void = { [weak self] in + guard let self, let component = self.component else { + return + } + let monoforumResult = self.calculateMonoforumResult() + let chatResult = self.calculateChatResult() + let liveStreamResult = self.calculateLiveStreamResult() + + dismiss(true) + + switch component.mode { + case let .monoforum(completion): + completion(monoforumResult) + case let .chat(_, _, completion): + completion(chatResult) + case let .liveStream(_, _, completion): + completion(liveStreamResult) + case let .chatReaction(completion): + completion(chatResult) } - transition.setFrame(view: leftButtonView, frame: leftButtonFrame) } - let containerInset: CGFloat = environment.statusBarHeight + 10.0 - - let clippingY: CGFloat - - enum OptionsSection { - case report - case deleteAll - case ban - } - - var availableOptions: [OptionsSection] = [] - availableOptions.append(.report) - - switch component.mode { - case .monoforum: - availableOptions.append(.ban) - case .chat: - if case let .channel(channel) = component.chatPeer { - if channel.hasPermission(.deleteAllMessages) { - availableOptions.append(.deleteAll) - - if channel.hasPermission(.banMembers) { - var canBanEveryone = true - for peer in component.peers { - if peer.peer.id == component.context.account.peerId { - canBanEveryone = false - continue - } - - switch peer.participant { - case .creator: - canBanEveryone = false - case let .member(_, _, adminInfo, banInfo, _, _): - let _ = banInfo - if let adminInfo { - if channel.flags.contains(.isCreator) { - } else if adminInfo.promotedBy == component.context.account.peerId { - } else { - canBanEveryone = false - } - } - } - } - - if canBanEveryone { - availableOptions.append(.ban) - } - } - } - } - case .liveStream: - availableOptions.append(.deleteAll) - availableOptions.append(.ban) - } - - let optionsItem: (OptionsSection) -> AnyComponentWithIdentity = { section in - let sectionId: AnyHashable - let selectedPeers: Set - let isExpanded: Bool - var title: String - - switch section { - case .report: - sectionId = "report" - selectedPeers = self.optionReportSelectedPeers - isExpanded = self.isOptionReportExpanded - - title = environment.strings.Chat_AdminActionSheet_ReportSpam - case .deleteAll: - sectionId = "delete-all" - selectedPeers = self.optionDeleteAllSelectedPeers - isExpanded = self.isOptionDeleteAllExpanded - - if component.peers.count == 1 { - title = environment.strings.Chat_AdminActionSheet_DeleteAllSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string - } else { - title = environment.strings.Chat_AdminActionSheet_DeleteAllMultiple - } - case .ban: - sectionId = "ban" - selectedPeers = self.optionBanSelectedPeers - isExpanded = self.isOptionBanExpanded - - let banTitle: String - let restrictTitle: String - if component.peers.count == 1 { - banTitle = environment.strings.Chat_AdminActionSheet_BanSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string - restrictTitle = environment.strings.Chat_AdminActionSheet_RestrictSingle(EnginePeer(component.peers[0].peer).compactDisplayTitle).string - } else { - banTitle = environment.strings.Chat_AdminActionSheet_BanMultiple - restrictTitle = environment.strings.Chat_AdminActionSheet_RestrictMultiple - } - title = self.isConfigurationExpanded ? restrictTitle : banTitle - } - - var accessory: ListActionItemComponent.Accessory? - if component.peers.count > 1 { - accessory = .custom(ListActionItemComponent.CustomAccessory( - component: AnyComponentWithIdentity(id: 0, component: AnyComponent(PlainButtonComponent( - content: AnyComponent(OptionSectionExpandIndicatorComponent( - theme: environment.theme, - count: selectedPeers.isEmpty ? component.peers.count : selectedPeers.count, - isExpanded: isExpanded - )), - effectAlignment: .center, - action: { [weak self] in - guard let self else { - return - } - - switch section { - case .report: - self.isOptionReportExpanded = !self.isOptionReportExpanded - case .deleteAll: - self.isOptionDeleteAllExpanded = !self.isOptionDeleteAllExpanded - case .ban: - self.isOptionBanExpanded = !self.isOptionBanExpanded - } - - self.state?.updated(transition: .spring(duration: 0.35)) - }, - animateScale: false - ))), - insets: UIEdgeInsets(top: 0.0, left: 6.0, bottom: 0.0, right: 2.0), - isInteractive: true - )) - } - - return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: title, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - ))), - ], alignment: .left, spacing: 2.0)), - leftIcon: .check(ListActionItemComponent.LeftIcon.Check( - isSelected: !selectedPeers.isEmpty, - toggle: { [weak self] in + let sheetSize = self.sheet.update( + transition: transition, + component: AnyComponent(ResizableSheetComponent( + content: AnyComponent(AdminUserActionsContentComponent( + context: component.context, + chatPeer: component.chatPeer, + peers: component.peers, + mode: component.mode, + theme: theme, + strings: environmentValue.strings, + presentationData: presentationData, + sheetState: currentState, + disableOptionsSectionAnimation: disableOptionsSectionAnimation, + toggleOptionSelection: { [weak self] section in guard let self, let component = self.component else { return } @@ -799,75 +1256,24 @@ private final class AdminUserActionsSheetComponent: Component { } self.state?.updated(transition: .spring(duration: 0.35)) - } - )), - icon: .none, - accessory: accessory, - action: { [weak self] _ in - guard let self else { - return - } - - var selectedPeers: Set - switch section { - case .report: - selectedPeers = self.optionReportSelectedPeers - case .deleteAll: - selectedPeers = self.optionDeleteAllSelectedPeers - case .ban: - selectedPeers = self.optionBanSelectedPeers - } - - if selectedPeers.isEmpty { - for peer in component.peers { - selectedPeers.insert(peer.peer.id) + }, + toggleOptionExpansion: { [weak self] section in + guard let self else { + return } - } else { - selectedPeers.removeAll() - } - - switch section { - case .report: - self.optionReportSelectedPeers = selectedPeers - case .deleteAll: - self.optionDeleteAllSelectedPeers = selectedPeers - case .ban: - self.optionBanSelectedPeers = selectedPeers - } - - self.state?.updated(transition: .spring(duration: 0.35)) - }, - highlighting: .disabled - ))) - } - - let expandedPeersItem: (OptionsSection) -> AnyComponentWithIdentity = { section in - let sectionId: AnyHashable - let selectedPeers: Set - switch section { - case .report: - sectionId = "report-peers" - selectedPeers = self.optionReportSelectedPeers - case .deleteAll: - sectionId = "delete-all-peers" - selectedPeers = self.optionDeleteAllSelectedPeers - case .ban: - sectionId = "ban-peers" - selectedPeers = self.optionBanSelectedPeers - } - - var peerItems: [AnyComponentWithIdentity] = [] - for peer in component.peers { - peerItems.append(AnyComponentWithIdentity(id: peer.peer.id, component: AnyComponent(AdminUserActionsPeerComponent( - context: component.context, - theme: environment.theme, - strings: environment.strings, - baseFontSize: presentationData.listsFontSize.baseDisplaySize, - sideInset: 0.0, - title: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: .firstLast), - peer: EnginePeer(peer.peer), - selectionState: .editing(isSelected: selectedPeers.contains(peer.peer.id)), - action: { [weak self] peer in + + switch section { + case .report: + self.isOptionReportExpanded = !self.isOptionReportExpanded + case .deleteAll: + self.isOptionDeleteAllExpanded = !self.isOptionDeleteAllExpanded + case .ban: + self.isOptionBanExpanded = !self.isOptionBanExpanded + } + + self.state?.updated(transition: .spring(duration: 0.35)) + }, + togglePeerSelection: { [weak self] section, peer in guard let self else { return } @@ -898,578 +1304,165 @@ private final class AdminUserActionsSheetComponent: Component { } self.state?.updated(transition: ComponentTransition(animation: .curve(duration: 0.3, curve: .easeInOut))) - } - )))) - } - return AnyComponentWithIdentity(id: sectionId, component: AnyComponent(ListSubSectionComponent( - theme: environment.theme, - leftInset: 62.0, - items: peerItems - ))) - } - - var titleString: String - switch component.mode { - case .monoforum: - if let peer = component.peers.first { - titleString = environment.strings.Monoforum_DeleteTopic_Title(EnginePeer(peer.peer).compactDisplayTitle).string - } else { - titleString = environment.strings.Common_Delete - } - case let .chat(messageCount, deleteAllMessageCount, _): - titleString = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(messageCount)) - if let deleteAllMessageCount { - if self.optionDeleteAllSelectedPeers == Set(component.peers.map(\.peer.id)) { - titleString = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(deleteAllMessageCount)) - } - } - case let .liveStream(messageCount, deleteAllMessageCount, _): - titleString = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(messageCount)) - if let deleteAllMessageCount { - if self.optionDeleteAllSelectedPeers == Set(component.peers.map(\.peer.id)) { - titleString = environment.strings.Chat_AdminActionSheet_DeleteTitle(Int32(deleteAllMessageCount)) - } - } - } - - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleString, font: Font.semibold(17.0), textColor: environment.theme.list.itemPrimaryTextColor)) - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - leftButtonFrame.maxX * 2.0, height: 100.0) - ) - let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: floor((54.0 - titleSize.height) * 0.5)), size: titleSize) - if let titleView = title.view { - if titleView.superview == nil { - self.navigationBarContainer.addSubview(titleView) - } - //transition.setPosition(view: titleView, position: titleFrame.center) - titleView.center = titleFrame.center - titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) - } - - let navigationBackgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: availableSize.width, height: 54.0)) - transition.setFrame(view: self.navigationBackgroundView, frame: navigationBackgroundFrame) - self.navigationBackgroundView.update(size: navigationBackgroundFrame.size, cornerRadius: 10.0, maskedCorners: [.layerMinXMinYCorner, .layerMaxXMinYCorner], transition: transition.containedViewLayoutTransition) - transition.setFrame(layer: self.navigationBarSeparator, frame: CGRect(origin: CGPoint(x: 0.0, y: 54.0), size: CGSize(width: availableSize.width, height: UIScreenPixel))) - - var optionsSectionItems: [AnyComponentWithIdentity] = [] - - for option in availableOptions { - let isOptionExpanded: Bool - switch option { - case .report: - isOptionExpanded = self.isOptionReportExpanded - case .deleteAll: - isOptionExpanded = self.isOptionDeleteAllExpanded - case .ban: - isOptionExpanded = self.isOptionBanExpanded - } - - optionsSectionItems.append(optionsItem(option)) - if isOptionExpanded { - optionsSectionItems.append(expandedPeersItem(option)) - } - } - - var optionsSectionTransition = transition - if self.previousWasConfigurationExpanded != self.isConfigurationExpanded { - self.previousWasConfigurationExpanded = self.isConfigurationExpanded - optionsSectionTransition = optionsSectionTransition.withAnimation(.none) - } - let optionsSectionSize = self.optionsSection.update( - transition: optionsSectionTransition, - component: AnyComponent(ListSectionComponent( - theme: environment.theme, - style: .glass, - header: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Chat_AdminActionSheet_RestrictSectionHeader, - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor - )), - maximumNumberOfLines: 0 - )), - footer: nil, - items: optionsSectionItems, - isModal: true - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) - ) - - let optionsSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight), size: optionsSectionSize) - if let optionsSectionView = self.optionsSection.view { - if optionsSectionView.superview == nil { - self.scrollContentView.addSubview(optionsSectionView) - self.optionsSection.parentState = state - } - transition.setFrame(view: optionsSectionView, frame: optionsSectionFrame) - } - contentHeight += optionsSectionSize.height - - let partiallyRestrictTitle: String - let fullyBanTitle: String - if component.peers.count == 1 { - partiallyRestrictTitle = environment.strings.Chat_AdminActionSheet_RestrictFooterSingle - fullyBanTitle = environment.strings.Chat_AdminActionSheet_BanFooterSingle - } else { - partiallyRestrictTitle = environment.strings.Chat_AdminActionSheet_RestrictFooterMultiple - fullyBanTitle = environment.strings.Chat_AdminActionSheet_BanFooterMultiple - } - - let optionsFooterSize = self.optionsFooter.update( - transition: transition, - component: AnyComponent(PlainButtonComponent( - content: AnyComponent(OptionsSectionFooterComponent( - theme: environment.theme, - text: self.isConfigurationExpanded ? fullyBanTitle : partiallyRestrictTitle, - fontSize: presentationData.listsFontSize.itemListBaseHeaderFontSize, - isExpanded: self.isConfigurationExpanded - )), - effectAlignment: .left, - contentInsets: UIEdgeInsets(), - action: { [weak self] in - guard let self, let component = self.component else { - return - } - self.isConfigurationExpanded = !self.isConfigurationExpanded - if self.isConfigurationExpanded && self.optionBanSelectedPeers.isEmpty { - for peer in component.peers { - self.optionBanSelectedPeers.insert(peer.peer.id) - } - } - self.state?.updated(transition: .spring(duration: 0.35)) - }, - animateAlpha: true, - animateScale: false, - animateContents: true - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 1000.0) - ) - - var configSectionItems: [AnyComponentWithIdentity] = [] - - enum ConfigItem: Hashable, CaseIterable { - case sendMessages - case sendMedia - case addUsers - case pinMessages - case changeInfo - } - - if case let .channel(channel) = component.chatPeer, channel.isMonoForum { - } else if case .liveStream = component.mode { - } else { - var allConfigItems: [(ConfigItem, Bool)] = [] - if !self.allowedMediaRights.isEmpty || !self.allowedParticipantRights.isEmpty { - for configItem in ConfigItem.allCases { - let isEnabled: Bool - switch configItem { - case .sendMessages: - isEnabled = self.allowedParticipantRights.contains(.sendMessages) - case .sendMedia: - isEnabled = !self.allowedMediaRights.isEmpty - case .addUsers: - isEnabled = self.allowedParticipantRights.contains(.addMembers) - case .pinMessages: - isEnabled = self.allowedParticipantRights.contains(.pinMessages) - case .changeInfo: - isEnabled = self.allowedParticipantRights.contains(.changeInfo) - } - allConfigItems.append((configItem, isEnabled)) - } - } - - loop: for (configItem, isEnabled) in allConfigItems { - let itemTitle: AnyComponent - let itemValue: Bool - switch configItem { - case .sendMessages: - itemTitle = AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_BanUser_PermissionSendMessages, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - )) - itemValue = self.participantRights.contains(.sendMessages) - case .sendMedia: - if isEnabled { - itemTitle = AnyComponent(HStack([ - AnyComponentWithIdentity(id: 0, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_BanUser_PermissionSendMedia, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - ))), - AnyComponentWithIdentity(id: 1, component: AnyComponent(MediaSectionExpandIndicatorComponent( - theme: environment.theme, - title: "\(self.mediaRights.count)/\(self.allowedMediaRights.count)", - isExpanded: self.isMediaSectionExpanded - ))) - ], spacing: 7.0)) - } else { - itemTitle = AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_BanUser_PermissionSendMedia, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - )) - } - - itemValue = !self.mediaRights.isEmpty - case .addUsers: - itemTitle = AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_BanUser_PermissionAddMembers, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - )) - itemValue = self.participantRights.contains(.addMembers) - case .pinMessages: - itemTitle = AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_EditAdmin_PermissionPinMessages, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - )) - itemValue = self.participantRights.contains(.pinMessages) - case .changeInfo: - itemTitle = AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: environment.strings.Channel_BanUser_PermissionChangeGroupInfo, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - )) - itemValue = self.participantRights.contains(.changeInfo) - } - - configSectionItems.append(AnyComponentWithIdentity(id: configItem, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: itemTitle, - accessory: .toggle(ListActionItemComponent.Toggle( - style: isEnabled ? .icons : .lock, - isOn: itemValue, - isInteractive: isEnabled, - action: isEnabled ? { [weak self] _ in - guard let self else { - return - } - - switch configItem { - case .sendMessages: - if self.participantRights.contains(.sendMessages) { - self.participantRights.remove(.sendMessages) - } else { - self.participantRights.insert(.sendMessages) - } - case .sendMedia: - if self.mediaRights.isEmpty { - self.mediaRights = self.allowedMediaRights - } else { - self.mediaRights = [] - } - case .addUsers: - if self.participantRights.contains(.addMembers) { - self.participantRights.remove(.addMembers) - } else { - self.participantRights.insert(.addMembers) - } - case .pinMessages: - if self.participantRights.contains(.pinMessages) { - self.participantRights.remove(.pinMessages) - } else { - self.participantRights.insert(.pinMessages) - } - case .changeInfo: - if self.participantRights.contains(.changeInfo) { - self.participantRights.remove(.changeInfo) - } else { - self.participantRights.insert(.changeInfo) - } - } - self.state?.updated(transition: .spring(duration: 0.35)) - } : nil - )), - action: ((isEnabled && configItem == .sendMedia) || !isEnabled) ? { [weak self] _ in + }, + toggleConfiguration: { [weak self] in guard let self, let component = self.component else { return } - if !isEnabled { - self.environment?.controller()?.present(textAlertController(context: component.context, title: nil, text: environment.strings.GroupPermission_PermissionDisabledByDefault, actions: [ - TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: { - }) - ]), in: .window(.root)) + self.isConfigurationExpanded = !self.isConfigurationExpanded + if self.isConfigurationExpanded && self.optionBanSelectedPeers.isEmpty { + for peer in component.peers { + self.optionBanSelectedPeers.insert(peer.peer.id) + } + } + self.state?.updated(transition: .spring(duration: 0.35)) + }, + toggleConfigItem: { [weak self] configItem in + guard let self else { + return + } + + switch configItem { + case .sendMessages: + if self.participantRights.contains(.sendMessages) { + self.participantRights.remove(.sendMessages) + } else { + self.participantRights.insert(.sendMessages) + } + case .sendMedia: + if self.mediaRights.isEmpty { + self.mediaRights = self.allowedMediaRights + } else { + self.mediaRights = [] + } + case .addUsers: + if self.participantRights.contains(.addMembers) { + self.participantRights.remove(.addMembers) + } else { + self.participantRights.insert(.addMembers) + } + case .pinMessages: + if self.participantRights.contains(.pinMessages) { + self.participantRights.remove(.pinMessages) + } else { + self.participantRights.insert(.pinMessages) + } + case .changeInfo: + if self.participantRights.contains(.changeInfo) { + self.participantRights.remove(.changeInfo) + } else { + self.participantRights.insert(.changeInfo) + } + } + self.state?.updated(transition: .spring(duration: 0.35)) + }, + toggleMediaSectionExpansion: { [weak self] in + guard let self else { + return + } + self.isMediaSectionExpanded = !self.isMediaSectionExpanded + self.state?.updated(transition: .spring(duration: 0.35)) + }, + toggleMediaRight: { [weak self] mediaRight in + guard let self else { + return + } + + if self.mediaRights.contains(mediaRight) { + self.mediaRights.remove(mediaRight) } else { - self.isMediaSectionExpanded = !self.isMediaSectionExpanded - self.state?.updated(transition: .spring(duration: 0.35)) - } - } : nil, - highlighting: .disabled - )))) - - if isEnabled, case .sendMedia = configItem, self.isMediaSectionExpanded { - var mediaItems: [AnyComponentWithIdentity] = [] - mediaRightsLoop: for possibleMediaItem in allMediaRightItems { - if !self.allowedMediaRights.contains(possibleMediaItem) { - continue + self.mediaRights.insert(mediaRight) } - let mediaItemTitle: String - switch possibleMediaItem { - case .photos: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendPhoto - case .videos: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendVideo - case .stickersAndGifs: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendStickersAndGifs - case .music: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendMusic - case .files: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendFile - case .voiceMessages: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendVoiceMessage - case .videoMessages: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendVideoMessage - case .links: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionEmbedLinks - case .polls: - mediaItemTitle = environment.strings.Channel_BanUser_PermissionSendPolls - default: - continue mediaRightsLoop - } - - mediaItems.append(AnyComponentWithIdentity(id: possibleMediaItem, component: AnyComponent(ListActionItemComponent( - theme: environment.theme, - title: AnyComponent(VStack([ - AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: mediaItemTitle, - font: Font.regular(presentationData.listsFontSize.baseDisplaySize), - textColor: environment.theme.list.itemPrimaryTextColor - )), - maximumNumberOfLines: 1 - ))), - ], alignment: .left, spacing: 2.0)), - leftIcon: .check(ListActionItemComponent.LeftIcon.Check( - isSelected: self.mediaRights.contains(possibleMediaItem), - toggle: { [weak self] in - guard let self else { - return - } - - if self.mediaRights.contains(possibleMediaItem) { - self.mediaRights.remove(possibleMediaItem) - } else { - self.mediaRights.insert(possibleMediaItem) - } - - self.state?.updated(transition: .spring(duration: 0.35)) - } - )), - icon: .none, - accessory: .none, - action: { [weak self] _ in - guard let self else { - return - } - - if self.mediaRights.contains(possibleMediaItem) { - self.mediaRights.remove(possibleMediaItem) - } else { - self.mediaRights.insert(possibleMediaItem) - } - - self.state?.updated(transition: .spring(duration: 0.35)) - }, - highlighting: .disabled - )))) + self.state?.updated(transition: .spring(duration: 0.35)) } - configSectionItems.append(AnyComponentWithIdentity(id: "media-sub", component: AnyComponent(ListSubSectionComponent( - theme: environment.theme, - leftInset: 0.0, - items: mediaItems - )))) - } - } - } - - let configSectionSize = self.configSection.update( - transition: transition, - component: AnyComponent(ListSectionComponent( - theme: environment.theme, - header: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString( - string: component.peers.count == 1 ? environment.strings.Chat_AdminActionSheet_PermissionsSectionHeader : environment.strings.Chat_AdminActionSheet_PermissionsSectionHeaderMultiple, - font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize), - textColor: environment.theme.list.freeTextColor - )), - maximumNumberOfLines: 0 )), - footer: nil, - items: configSectionItems - )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100000.0) - ) - let configSectionFrame = CGRect(origin: CGPoint(x: sideInset, y: contentHeight + 30.0), size: configSectionSize) - if let configSectionView = self.configSection.view { - if configSectionView.superview == nil { - configSectionView.clipsToBounds = true - configSectionView.layer.cornerRadius = 11.0 - self.scrollContentView.addSubview(configSectionView) - self.configSection.parentState = state - } - let effectiveConfigSectionFrame: CGRect - if self.isConfigurationExpanded { - effectiveConfigSectionFrame = configSectionFrame - } else { - effectiveConfigSectionFrame = CGRect(origin: CGPoint(x: configSectionFrame.minX, y: configSectionFrame.minY - 30.0), size: CGSize(width: configSectionFrame.width, height: 0.0)) - } - transition.setFrame(view: configSectionView, frame: effectiveConfigSectionFrame) - transition.setAlpha(view: configSectionView, alpha: self.isConfigurationExpanded ? 1.0 : 0.0) - } - - if availableOptions.contains(.ban) && !configSectionItems.isEmpty { - let optionsFooterFrame: CGRect - if self.isConfigurationExpanded { - contentHeight += 30.0 - contentHeight += configSectionSize.height - contentHeight += 7.0 - optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) - contentHeight += optionsFooterSize.height - } else { - contentHeight += 7.0 - optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) - contentHeight += optionsFooterSize.height - } - if let optionsFooterView = self.optionsFooter.view { - if optionsFooterView.superview == nil { - self.scrollContentView.addSubview(optionsFooterView) - } - transition.setFrame(view: optionsFooterView, frame: optionsFooterFrame) - transition.setAlpha(view: optionsFooterView, alpha: 1.0) - } - } else { - if let optionsFooterView = self.optionsFooter.view { - if optionsFooterView.superview == nil { - self.scrollContentView.addSubview(optionsFooterView) - } - let optionsFooterFrame = CGRect(origin: CGPoint(x: sideInset + 16.0, y: contentHeight), size: optionsFooterSize) - transition.setFrame(view: optionsFooterView, frame: optionsFooterFrame) - transition.setAlpha(view: optionsFooterView, alpha: 0.0) - } - } - - contentHeight += 30.0 - - let actionButtonSize = self.actionButton.update( - transition: transition, - component: AnyComponent(ButtonComponent( - background: ButtonComponent.Background( - style: .glass, - color: environment.theme.list.itemCheckColors.fillColor, - foreground: environment.theme.list.itemCheckColors.foregroundColor, - pressedColor: environment.theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), - cornerRadius: 54.0 * 0.5 + titleItem: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString( + string: adminUserActionsTitle( + strings: environmentValue.strings, + mode: component.mode, + peers: component.peers, + selectedDeleteAllPeers: self.optionDeleteAllSelectedPeers + ), + font: Font.semibold(17.0), + textColor: theme.list.itemPrimaryTextColor + )), + maximumNumberOfLines: 1 + )), + leftItem: AnyComponent( + GlassBarButtonComponent( + size: CGSize(width: 44.0, height: 44.0), + backgroundColor: nil, + isDark: theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "close", component: AnyComponent( + BundleIconComponent( + name: "Navigation/Close", + tintColor: theme.chat.inputPanel.panelControlColor + ) + )), + action: { _ in + dismiss(true) + } + ) ), - content: AnyComponentWithIdentity( - id: AnyHashable(0), - component: AnyComponent(ButtonTextContentComponent( - text: environment.strings.Chat_AdminActionSheet_ActionButton, - badge: 0, - textColor: environment.theme.list.itemCheckColors.foregroundColor, - badgeBackground: environment.theme.list.itemCheckColors.foregroundColor, - badgeForeground: environment.theme.list.itemCheckColors.fillColor - )) - ), - isEnabled: true, - displaysProgress: false, - action: { [weak self] in - guard let self, let component = self.component else { - return + bottomItem: AnyComponent(ButtonComponent( + background: ButtonComponent.Background( + style: .glass, + color: theme.list.itemCheckColors.fillColor, + foreground: theme.list.itemCheckColors.foregroundColor, + pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9), + cornerRadius: 54.0 * 0.5 + ), + content: AnyComponentWithIdentity( + id: AnyHashable(0), + component: AnyComponent(ButtonTextContentComponent( + text: environmentValue.strings.Chat_AdminActionSheet_ActionButton, + badge: 0, + textColor: theme.list.itemCheckColors.foregroundColor, + badgeBackground: theme.list.itemCheckColors.foregroundColor, + badgeForeground: theme.list.itemCheckColors.fillColor + )) + ), + isEnabled: true, + displaysProgress: false, + action: { + performMainAction() } - self.environment?.controller()?.dismiss() - - switch component.mode { - case let .monoforum(completion): - completion(self.calculateMonoforumResult()) - case let .chat(_, _, completion): - completion(self.calculateChatResult()) - case let .liveStream(_, _, completion): - completion(self.calculateLiveStreamResult()) - } - } + )), + backgroundColor: .color(theme.list.modalBlocksBackgroundColor), + animateOut: self.animateOut )), - environment: {}, - containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 54.0) + environment: { + environmentValue + ResizableSheetComponentEnvironment( + theme: theme, + statusBarHeight: environmentValue.statusBarHeight, + safeInsets: environmentValue.safeInsets, + inputHeight: 0.0, + metrics: environmentValue.metrics, + deviceMetrics: environmentValue.deviceMetrics, + isDisplaying: environmentValue.isVisible, + isCentered: environmentValue.metrics.widthClass == .regular, + screenSize: availableSize, + regularMetricsSize: nil, + dismiss: { animated in + dismiss(animated) + } + ) + }, + forceUpdate: true, + containerSize: availableSize ) - let bottomPanelHeight = 8.0 + environment.safeInsets.bottom + actionButtonSize.height - let actionButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: availableSize.height - bottomPanelHeight), size: actionButtonSize) - if let actionButtonView = actionButton.view { - if actionButtonView.superview == nil { - self.addSubview(actionButtonView) + self.sheet.parentState = state + if let sheetView = self.sheet.view { + if sheetView.superview == nil { + self.addSubview(sheetView) } - transition.setFrame(view: actionButtonView, frame: actionButtonFrame) + transition.setFrame(view: sheetView, frame: CGRect(origin: .zero, size: sheetSize)) } - contentHeight += bottomPanelHeight - - clippingY = actionButtonFrame.minY - 24.0 - - let topInset: CGFloat = max(0.0, availableSize.height - containerInset - contentHeight) - - let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset) - - self.scrollContentClippingView.layer.cornerRadius = 10.0 - - self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, bottomInset: environment.safeInsets.bottom, topInset: topInset) - - transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight))) - - transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0)) - transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: availableSize)) - - let scrollClippingFrame = CGRect(origin: CGPoint(x: sideInset, y: containerInset), size: CGSize(width: availableSize.width - sideInset * 2.0, height: clippingY - containerInset)) - transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center) - transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size)) - - self.ignoreScrolling = true - let previousBounds = self.scrollView.bounds - transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height))) - let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight) - if contentSize != self.scrollView.contentSize { - self.scrollView.contentSize = contentSize - } - if resetScrolling { - self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize) - } else { - if !previousBounds.isEmpty, !transition.animation.isImmediate { - let bounds = self.scrollView.bounds - if bounds.maxY != previousBounds.maxY { - let offsetY = previousBounds.maxY - bounds.maxY - transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: offsetY), to: CGPoint(), additive: true) - } - } - } - self.ignoreScrolling = false - self.updateScrolling(transition: transition) - return availableSize } } @@ -1488,17 +1481,20 @@ public class AdminUserActionsSheet: ViewControllerComponentContainer { case chat(messageCount: Int, deleteAllMessageCount: Int?, completion: (ChatResult) -> Void) case liveStream(messageCount: Int, deleteAllMessageCount: Int?, completion: (LiveStreamResult) -> Void) case monoforum(completion: (MonoforumResult) -> Void) + case chatReaction(completion: (ChatResult) -> Void) } public final class ChatResult { public let reportSpamPeers: [EnginePeer.Id] public let deleteAllFromPeers: [EnginePeer.Id] + public let deleteAllReactionsFromPeers: [EnginePeer.Id] public let banPeers: [EnginePeer.Id] public let updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights] - init(reportSpamPeers: [EnginePeer.Id], deleteAllFromPeers: [EnginePeer.Id], banPeers: [EnginePeer.Id], updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights]) { + init(reportSpamPeers: [EnginePeer.Id], deleteAllFromPeers: [EnginePeer.Id], deleteAllReactionsFromPeers: [EnginePeer.Id], banPeers: [EnginePeer.Id], updateBannedRights: [EnginePeer.Id: TelegramChatBannedRights]) { self.reportSpamPeers = reportSpamPeers self.deleteAllFromPeers = deleteAllFromPeers + self.deleteAllReactionsFromPeers = deleteAllReactionsFromPeers self.banPeers = banPeers self.updateBannedRights = updateBannedRights } @@ -1527,12 +1523,17 @@ public class AdminUserActionsSheet: ViewControllerComponentContainer { } private let context: AccountContext - private var isDismissed: Bool = false + private var dismissCompletion: (() -> Void)? public init(context: AccountContext, chatPeer: EnginePeer, peers: [RenderedChannelParticipant], mode: Mode, customTheme: PresentationTheme? = nil) { self.context = context - super.init(context: context, component: AdminUserActionsSheetComponent(context: context, chatPeer: chatPeer, peers: peers, mode: mode), navigationBarAppearance: .none, theme: customTheme.flatMap({ .custom($0) }) ?? .default) + super.init( + context: context, + component: AdminUserActionsSheetComponent(context: context, chatPeer: chatPeer, peers: peers, mode: mode), + navigationBarAppearance: .none, + theme: customTheme.flatMap({ .custom($0) }) ?? .default + ) self.statusBar.statusBarStyle = .Ignore self.navigationPresentation = .flatModal @@ -1548,24 +1549,30 @@ public class AdminUserActionsSheet: ViewControllerComponentContainer { override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - self.view.disablesInteractiveModalDismiss = true - - if let componentView = self.node.hostView.componentView as? AdminUserActionsSheetComponent.View { - componentView.animateIn() + } + + fileprivate func completePendingDismiss() { + let dismissCompletion = self.dismissCompletion + self.dismissCompletion = nil + dismissCompletion?() + } + + public func dismissAnimated() { + if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent.View.Tag()) as? ResizableSheetComponent.View { + view.dismissAnimated() } } override public func dismiss(completion: (() -> Void)? = nil) { if !self.isDismissed { self.isDismissed = true + self.dismissCompletion = completion - if let componentView = self.node.hostView.componentView as? AdminUserActionsSheetComponent.View { - componentView.animateOut(completion: { [weak self] in - completion?() - self?.dismiss(animated: false) - }) + if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent.View.Tag()) as? ResizableSheetComponent.View { + view.dismissAnimated() } else { + self.completePendingDismiss() self.dismiss(animated: false) } } diff --git a/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift b/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift index 542ac3ba4c..2b28a3c333 100644 --- a/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift +++ b/submodules/TelegramUI/Components/ButtonComponent/Sources/ButtonComponent.swift @@ -465,7 +465,7 @@ public final class ButtonComponent: Component { private var glassShadowCornerRadius: CGFloat? private var glassHighlightContainerView: UIView? private let button: HighlightTrackingButton - private let legacyGlassHighlightRecognizer: GlassHighlightGestureRecognizer + private let glassHighlightRecognizer: GlassHighlightGestureRecognizer private var shimmeringView: ButtonShimmeringView? private var chromeView: UIImageView? @@ -480,7 +480,7 @@ public final class ButtonComponent: Component { self.containerView.isUserInteractionEnabled = false self.button = HighlightTrackingButton() - self.legacyGlassHighlightRecognizer = GlassHighlightGestureRecognizer(target: nil, action: nil) + self.glassHighlightRecognizer = GlassHighlightGestureRecognizer(target: nil, action: nil) super.init(frame: frame) @@ -489,8 +489,8 @@ public final class ButtonComponent: Component { self.addSubview(self.containerView) self.addSubview(self.button) - self.addGestureRecognizer(self.legacyGlassHighlightRecognizer) - self.legacyGlassHighlightRecognizer.isEnabled = false + self.addGestureRecognizer(self.glassHighlightRecognizer) + self.glassHighlightRecognizer.isEnabled = false self.button.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) @@ -521,33 +521,18 @@ public final class ButtonComponent: Component { preconditionFailure() } - private func ensureGlassShadowView() -> UIImageView { - if let glassShadowView = self.glassShadowView { - return glassShadowView - } - let glassShadowView = UIImageView() - glassShadowView.isUserInteractionEnabled = false - self.glassShadowView = glassShadowView - return glassShadowView - } - - private func ensureGlassHighlightContainerView() -> UIView { - if let glassHighlightContainerView = self.glassHighlightContainerView { - return glassHighlightContainerView - } - let glassHighlightContainerView = UIView() - glassHighlightContainerView.isUserInteractionEnabled = false - glassHighlightContainerView.clipsToBounds = true - self.glassHighlightContainerView = glassHighlightContainerView - return glassHighlightContainerView - } - - private func removeLegacyGlassEffectViews() { - self.legacyGlassHighlightRecognizer.isEnabled = false - self.legacyGlassHighlightRecognizer.highlightContainerView = nil + private func removeGlassEffect(transition: ComponentTransition) { + self.glassHighlightRecognizer.isEnabled = false + self.glassHighlightRecognizer.highlightContainerView = nil if let glassShadowView = self.glassShadowView, glassShadowView.superview != nil { - glassShadowView.removeFromSuperview() + if transition.animation.isImmediate { + glassShadowView.removeFromSuperview() + } else { + transition.setAlpha(view: glassShadowView, alpha: 0.0, completion: { _ in + glassShadowView.removeFromSuperview() + }) + } } if let glassHighlightContainerView = self.glassHighlightContainerView, glassHighlightContainerView.superview != nil { glassHighlightContainerView.removeFromSuperview() @@ -558,10 +543,17 @@ public final class ButtonComponent: Component { self.layer.sublayerTransform = CATransform3DIdentity } - private func updateLegacyGlassEffectViews(component: ButtonComponent, size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { + private func updateGlassEffect(component: ButtonComponent, size: CGSize, cornerRadius: CGFloat, transition: ComponentTransition) { let shadowInset: CGFloat = 48.0 - let glassShadowView = self.ensureGlassShadowView() + let glassShadowView: UIImageView + if let current = self.glassShadowView { + glassShadowView = current + } else { + glassShadowView = UIImageView() + glassShadowView.isUserInteractionEnabled = false + self.glassShadowView = glassShadowView + } if glassShadowView.superview == nil { self.insertSubview(glassShadowView, at: 0) } else { @@ -574,7 +566,15 @@ public final class ButtonComponent: Component { transition.setFrame(view: glassShadowView, frame: CGRect(origin: .zero, size: size).insetBy(dx: -shadowInset, dy: -shadowInset)) transition.setAlpha(view: glassShadowView, alpha: 1.0) - let glassHighlightContainerView = self.ensureGlassHighlightContainerView() + let glassHighlightContainerView: UIView + if let current = self.glassHighlightContainerView { + glassHighlightContainerView = current + } else { + glassHighlightContainerView = UIView() + glassHighlightContainerView.isUserInteractionEnabled = false + glassHighlightContainerView.clipsToBounds = true + self.glassHighlightContainerView = glassHighlightContainerView + } if glassHighlightContainerView.superview == nil { self.insertSubview(glassHighlightContainerView, aboveSubview: self.containerView) } else if self.button.superview === self { @@ -585,8 +585,8 @@ public final class ButtonComponent: Component { transition.setFrame(view: glassHighlightContainerView, frame: CGRect(origin: .zero, size: size)) transition.setCornerRadius(layer: glassHighlightContainerView.layer, cornerRadius: cornerRadius) - self.legacyGlassHighlightRecognizer.highlightContainerView = glassHighlightContainerView - self.legacyGlassHighlightRecognizer.isEnabled = component.isEnabled && !component.displaysProgress + self.glassHighlightRecognizer.highlightContainerView = glassHighlightContainerView + self.glassHighlightRecognizer.isEnabled = component.isEnabled && !component.displaysProgress } @objc private func pressed() { @@ -694,10 +694,10 @@ public final class ButtonComponent: Component { transition.setCornerRadius(layer: self.containerView.layer, cornerRadius: cornerRadius) } - if component.background.style == .glass { - self.updateLegacyGlassEffectViews(component: component, size: size, cornerRadius: cornerRadius, transition: transition) + if component.background.style == .glass, component.background.color.alpha > 1.0 - .ulpOfOne { + self.updateGlassEffect(component: component, size: size, cornerRadius: cornerRadius, transition: transition) } else { - self.removeLegacyGlassEffectViews() + self.removeGlassEffect(transition: transition) } if let contentView = contentItem.view.view { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift index d3dbc8ef0b..dc1f534a6f 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessagePollBubbleContentNode/Sources/ChatMessagePollBubbleContentNode.swift @@ -2684,8 +2684,9 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { var pollOptionsFinalizeLayouts: [(hasResult: Bool, layout: (CGFloat) -> (CGSize, (Bool, Bool, Bool) -> ChatMessagePollOptionNode))] = [] var addOptionFinalizeLayout: ((CGFloat) -> (CGSize, (Bool, Bool) -> ChatMessagePollAddOptionNode))? var orderedPollOptions: [(Int, TelegramMediaPollOption)] = [] + + var isRestricted = false if let poll = poll { - var isRestricted = false if !poll.countries.isEmpty, let accountCountry = item.associatedData.accountCountry, !poll.countries.contains(accountCountry) { isRestricted = true } @@ -2842,430 +2843,428 @@ public class ChatMessagePollBubbleContentNode: ChatMessageBubbleContentNode { let buttonViewResultsTextFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonViewResultsTextLayout.size.width) / 2.0), y: optionsButtonSpacing), size: buttonViewResultsTextLayout.size) return (resultSize, { [weak self] animation, synchronousLoad, _ in - if let strongSelf = self { - strongSelf.item = item - strongSelf.poll = poll - - let cachedLayout = strongSelf.textNode.textNode.cachedLayout - if case .System = animation { - if let cachedLayout = cachedLayout { - if !cachedLayout.areLinesEqual(to: textLayout) { - if let textContents = strongSelf.textNode.textNode.contents { - let fadeNode = ASDisplayNode() - fadeNode.displaysAsynchronously = false - fadeNode.contents = textContents - fadeNode.frame = strongSelf.textNode.textNode.frame - fadeNode.isLayerBacked = true - strongSelf.addSubnode(fadeNode) - fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in - fadeNode?.removeFromSupernode() - }) - strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - } + if let strongSelf = self { + strongSelf.item = item + strongSelf.poll = poll + + let cachedLayout = strongSelf.textNode.textNode.cachedLayout + if case .System = animation { + if let cachedLayout = cachedLayout { + if !cachedLayout.areLinesEqual(to: textLayout) { + if let textContents = strongSelf.textNode.textNode.contents { + let fadeNode = ASDisplayNode() + fadeNode.displaysAsynchronously = false + fadeNode.contents = textContents + fadeNode.frame = strongSelf.textNode.textNode.frame + fadeNode.isLayerBacked = true + strongSelf.addSubnode(fadeNode) + fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in + fadeNode?.removeFromSupernode() + }) + strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } } - - let _ = textApply(TextNodeWithEntities.Arguments( - context: item.context, - cache: item.context.animationCache, - renderer: item.context.animationRenderer, - placeholderColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : item.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, - attemptSynchronous: synchronousLoad) - ) - let _ = typeApply() - - var verticalOffset = textFrame.maxY + titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing - var updatedOptionNodes: [ChatMessagePollOptionNode] = [] - for i in 0 ..< optionNodesSizesAndApply.count { - let (size, apply) = optionNodesSizesAndApply[i] - var isRequesting = false - if i < orderedPollOptions.count { - if let inProgressOpaqueIds = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] { - isRequesting = inProgressOpaqueIds.contains(orderedPollOptions[i].1.opaqueIdentifier) - } + } + + let _ = textApply(TextNodeWithEntities.Arguments( + context: item.context, + cache: item.context.animationCache, + renderer: item.context.animationRenderer, + placeholderColor: incoming ? item.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : item.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor, + attemptSynchronous: synchronousLoad) + ) + let _ = typeApply() + + var verticalOffset = textFrame.maxY + titleTypeSpacing + typeLayout.size.height + typeOptionsSpacing + var updatedOptionNodes: [ChatMessagePollOptionNode] = [] + for i in 0 ..< optionNodesSizesAndApply.count { + let (size, apply) = optionNodesSizesAndApply[i] + var isRequesting = false + if i < orderedPollOptions.count { + if let inProgressOpaqueIds = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] { + isRequesting = inProgressOpaqueIds.contains(orderedPollOptions[i].1.opaqueIdentifier) } - let optionNode = apply(animation.isAnimated, isRequesting, synchronousLoad) - let optionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size) - if optionNode.supernode !== strongSelf { - strongSelf.addSubnode(optionNode) - let option = optionNode.option - - optionNode.pressed = { [weak self] in - guard let self, - let item = self.item, - let option else { - return - } - item.controllerInteraction.requestSelectMessagePollOptions(item.message.id, [option.opaqueIdentifier]) + } + let optionNode = apply(animation.isAnimated, isRequesting, synchronousLoad) + let optionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size) + if optionNode.supernode !== strongSelf { + strongSelf.addSubnode(optionNode) + let option = optionNode.option + + optionNode.pressed = { [weak self] in + guard let self, + let item = self.item, + let option else { + return } - optionNode.resultPressed = { [weak self] in - guard let self, - let item = self.item, - let option else { - return - } - if let poll { - if case .public = poll.publicity { - item.controllerInteraction.openMessagePollResults(item.message.id, option.opaqueIdentifier) - } else { - if "".isEmpty { - let locale = localeWithStrings(item.presentationData.strings) - let countryNames = poll.countries.map { id in - if let countryName = locale.localizedString(forRegionCode: id) { - return countryName - } else { - return id - } - } - var countries: String = "" - if countryNames.count == 1, let country = countryNames.first { - countries = "**\(country)**" - } else { - for i in 0 ..< countryNames.count { - countries.append("**\(countryNames[i])**") - if i == countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) - } else if i < countryNames.count - 2 { - countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) - } - } - } - //TODO:localize - let controller = UndoOverlayController( - presentationData: item.context.sharedContext.currentPresentationData.with { $0 }, - content: .banned(text: "Only users from \(countries) can vote."), - elevatedLayout: true, - position: .bottom, - action: { _ in return true } - ) - item.controllerInteraction.presentController(controller, nil) + item.controllerInteraction.requestSelectMessagePollOptions(item.message.id, [option.opaqueIdentifier]) + } + optionNode.resultPressed = { [weak self] in + guard let self, + let item = self.item, + let option else { + return + } + if let poll { + if case .public = poll.publicity { + item.controllerInteraction.openMessagePollResults(item.message.id, option.opaqueIdentifier) + } else if isRestricted { + let locale = localeWithStrings(item.presentationData.strings) + let countryNames = poll.countries.map { id in + if let countryName = locale.localizedString(forRegionCode: id) { + return countryName + } else { + return id } } + var countries: String = "" + if countryNames.count == 1, let country = countryNames.first { + countries = "**\(country)**" + } else { + for i in 0 ..< countryNames.count { + countries.append("**\(countryNames[i])**") + if i == countryNames.count - 2 { + countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesLastDelimiter) + } else if i < countryNames.count - 2 { + countries.append(item.presentationData.strings.Chat_Poll_Restriction_Country_CountriesDelimiter) + } + } + } + //TODO:localize + let controller = UndoOverlayController( + presentationData: item.context.sharedContext.currentPresentationData.with { $0 }, + content: .banned(text: "Only users from \(countries) can vote."), + elevatedLayout: true, + position: .bottom, + action: { _ in return true } + ) + item.controllerInteraction.presentController(controller, nil) } } - optionNode.selectionUpdated = { [weak self] in - guard let self else { - return - } - self.updateSelection() - } - optionNode.longTapped = { [weak self] in - guard let self, - let item = self.item, - let option else { - return - } - item.controllerInteraction.pollOptionLongTap(option.opaqueIdentifier, ChatControllerInteraction.LongTapParams(message: item.message, contentNode: optionNode.contextSourceNode, messageNode: strongSelf, progress: nil)) - } - optionNode.frame = optionNodeFrame - } else { - animation.animator.updateFrame(layer: optionNode.layer, frame: optionNodeFrame, completion: nil) } - - if optionNode.currentResult != nil { - verticalOffset += size.height - 7.0 - } else { - verticalOffset += size.height - } - updatedOptionNodes.append(optionNode) - optionNode.isUserInteractionEnabled = !strongSelf.newOptionIsFocused && item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] == nil - optionNode.alpha = strongSelf.newOptionIsFocused ? 0.5 : 1.0 - - if i > 0 { - optionNode.previousOptionNode = updatedOptionNodes[i - 1] - } else { - optionNode.previousOptionNode = nil - } - } - for optionNode in strongSelf.optionNodes { - if !updatedOptionNodes.contains(where: { $0 === optionNode }) { - optionNode.removeFromSupernode() - } - } - strongSelf.optionNodes = updatedOptionNodes - strongSelf.updatePollOptionsInteraction(animated: animation.isAnimated) - - if let (size, apply) = addOptionSizeAndApply { - let isRequesting = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil - let addOptionNode = apply(animation.isAnimated, isRequesting) - let addOptionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size) - if addOptionNode.supernode !== strongSelf { - strongSelf.addSubnode(addOptionNode) - } else { - animation.animator.updateFrame(layer: addOptionNode.layer, frame: addOptionNodeFrame, completion: nil) - } - addOptionNode.frame = addOptionNodeFrame - addOptionNode.isUserInteractionEnabled = !isRequesting - addOptionNode.textUpdated = { [weak self] text in - self?.updateNewOptionText(text) - } - addOptionNode.heightUpdated = { [weak self] in - self?.requestNewOptionLayoutUpdate() - } - addOptionNode.attachPressed = { [weak self] in - self?.openNewOptionAttachment() - } - addOptionNode.mediaPressed = { [weak self] in - self?.openNewOptionAttachment() - } - addOptionNode.modeSelectorPressed = { [weak self] in - self?.toggleNewOptionInputMode() - } - addOptionNode.requestSave = { [weak self] in - self?.buttonPressed() - } - addOptionNode.focusUpdated = { [weak self] focused in + optionNode.selectionUpdated = { [weak self] in guard let self else { return } - self.updatePollAddOptionFocused(focused) + self.updateSelection() } - strongSelf.addOptionNode = addOptionNode + optionNode.longTapped = { [weak self] in + guard let self, + let item = self.item, + let option else { + return + } + item.controllerInteraction.pollOptionLongTap(option.opaqueIdentifier, ChatControllerInteraction.LongTapParams(message: item.message, contentNode: optionNode.contextSourceNode, messageNode: strongSelf, progress: nil)) + } + optionNode.frame = optionNodeFrame + } else { + animation.animator.updateFrame(layer: optionNode.layer, frame: optionNodeFrame, completion: nil) + } + + if optionNode.currentResult != nil { + verticalOffset += size.height - 7.0 + } else { verticalOffset += size.height - } else if let addOptionNode = strongSelf.addOptionNode { - strongSelf.updatePollAddOptionFocused(false) - strongSelf.addOptionNode = nil - addOptionNode.removeFromSupernode() } + updatedOptionNodes.append(optionNode) + optionNode.isUserInteractionEnabled = !strongSelf.newOptionIsFocused && item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] == nil + optionNode.alpha = strongSelf.newOptionIsFocused ? 0.5 : 1.0 - if let poll = poll, let pendingNewOptionSubmissionText, let pendingNewOptionOptionCount = strongSelf.pendingNewOptionOptionCount, poll.options.count > pendingNewOptionOptionCount, poll.options.contains(where: { $0.text == pendingNewOptionSubmissionText }) { - strongSelf.clearNewOptionInput() - } - - if textLayout.hasRTL { - strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size) + if i > 0 { + optionNode.previousOptionNode = updatedOptionNodes[i - 1] } else { - strongSelf.textNode.textNode.frame = textFrame + optionNode.previousOptionNode = nil } - let typeFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + titleTypeSpacing), size: typeLayout.size) - animation.animator.updateFrame(layer: strongSelf.typeNode.layer, frame: typeFrame, completion: nil) - - let deadlineTimeout = poll?.deadlineTimeout - var displayDeadlineTimer = true - var hasSelected = false - - if let poll { - if let voters = poll.results.voters { - for voter in voters { - if voter.selected { - if case .quiz = poll.kind { - displayDeadlineTimer = false - } else { - displayDeadlineTimer = !poll.isClosed - } - hasSelected = true - break - } - } - } - } - - var endDate: Int32? - if let deadlineTimeout, - message.id.namespace == Namespaces.Message.Cloud { - let startDate: Int32 - if let forwardInfo = message.forwardInfo { - startDate = forwardInfo.date - } else { - startDate = message.timestamp - } - endDate = startDate + deadlineTimeout - } - - if let poll, case .quiz = poll.kind, let deadlineTimeout, !isClosed { - let timerNode: PollBubbleTimerNode - if let current = strongSelf.timerNode { - timerNode = current - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - if displayDeadlineTimer { - timerTransition.updateAlpha(node: timerNode, alpha: 1.0) - } else { - timerTransition.updateAlpha(node: timerNode, alpha: 0.0) - } - } else { - timerNode = PollBubbleTimerNode() - strongSelf.timerNode = timerNode - strongSelf.addSubnode(timerNode) - timerNode.reachedTimeout = { - guard let strongSelf = self, - let _ = strongSelf.item else { - return - } - //item.controllerInteraction.requestMessageUpdate(item.message.id) - } - - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - if displayDeadlineTimer { - timerNode.alpha = 0.0 - timerTransition.updateAlpha(node: timerNode, alpha: 1.0) - } else { - timerNode.alpha = 0.0 - } - } - timerNode.update(regularColor: messageTheme.secondaryTextColor, proximityColor: messageTheme.scamColor, timeout: deadlineTimeout, deadlineTimestamp: endDate) - timerNode.frame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right, y: typeFrame.minY), size: CGSize()) - } else if let timerNode = strongSelf.timerNode { - strongSelf.timerNode = nil - - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in - timerNode?.removeFromSupernode() - }) - timerTransition.updateTransformScale(node: timerNode, scale: 0.1) - } - - var statusOffset: CGFloat = 0.0 - if let poll, case .poll = poll.kind, let endDate, !isClosed { - let timerNode: DeadlineTimerNode - if let current = strongSelf.deadlineTimerNode { - timerNode = current - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - if displayDeadlineTimer { - timerTransition.updateAlpha(node: timerNode, alpha: 1.0) - } else { - timerTransition.updateAlpha(node: timerNode, alpha: 0.0) - } - } else { - timerNode = DeadlineTimerNode() - strongSelf.deadlineTimerNode = timerNode - strongSelf.addSubnode(timerNode) - - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - if displayDeadlineTimer { - timerNode.alpha = 0.0 - timerTransition.updateAlpha(node: timerNode, alpha: 1.0) - } else { - timerNode.alpha = 0.0 - } - } - timerNode.update(size: resultSize, color: messageTheme.secondaryTextColor, deadlineTimeout: endDate, resultsHidden: poll.hideResultsUntilClose, strings: item.presentationData.strings) - timerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset + 31.0), size: CGSize(width: resultSize.width, height: 20.0)) - statusOffset += 6.0 - } else if let timerNode = strongSelf.deadlineTimerNode { - strongSelf.deadlineTimerNode = nil - - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in - timerNode?.removeFromSupernode() - }) - timerTransition.updateTransformScale(node: timerNode, scale: 0.1) - } - - let solutionButtonSize = CGSize(width: 32.0, height: 32.0) - let solutionButtonFrame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right - solutionButtonSize.width + 5.0, y: typeFrame.minY - 16.0), size: solutionButtonSize) - strongSelf.solutionButtonNode.frame = solutionButtonFrame - - if (strongSelf.timerNode == nil || !displayDeadlineTimer), let poll = poll, case .quiz = poll.kind, let _ = poll.results.solution, (isClosed || hasSelected) { - if strongSelf.solutionButtonNode.alpha.isZero { - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 1.0) - } - strongSelf.solutionButtonNode.update(size: solutionButtonSize, theme: item.presentationData.theme.theme, incoming: incoming) - } else if !strongSelf.solutionButtonNode.alpha.isZero { - let timerTransition: ContainedViewLayoutTransition - if animation.isAnimated { - timerTransition = .animated(duration: 0.25, curve: .easeInOut) - } else { - timerTransition = .immediate - } - timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 0.0) - } - - let avatarsFrame = CGRect(origin: CGPoint(x: typeFrame.maxX + 6.0, y: typeFrame.minY + floor((typeFrame.height - MergedAvatarsNode.defaultMergedImageSize) / 2.0)), size: CGSize(width: MergedAvatarsNode.defaultMergedImageSize + MergedAvatarsNode.defaultMergedImageSpacing * 2.0, height: MergedAvatarsNode.defaultMergedImageSize)) - strongSelf.avatarsNode.frame = avatarsFrame - strongSelf.avatarsNode.updateLayout(size: avatarsFrame.size) - strongSelf.avatarsNode.update(context: item.context, peers: avatarPeers, synchronousLoad: synchronousLoad, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth) - strongSelf.avatarsNode.isHidden = isBotChat - let alphaTransition: ContainedViewLayoutTransition - if animation.isAnimated { - alphaTransition = .animated(duration: 0.25, curve: .easeInOut) - alphaTransition.updateAlpha(node: strongSelf.avatarsNode, alpha: avatarPeers.isEmpty ? 0.0 : 1.0) - } else { - alphaTransition = .immediate - } - - let _ = votersApply() - let votersFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - votersLayout.size.width) / 2.0), y: verticalOffset + optionsVotersSpacing), size: votersLayout.size) - animation.animator.updateFrame(layer: strongSelf.votersNode.layer, frame: votersFrame, completion: nil) - - if animation.isAnimated, let previousPoll = previousPoll, let poll = poll { - if previousPoll.results.totalVoters == nil && poll.results.totalVoters != nil { - strongSelf.votersNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) - } - } - - if let statusSizeAndApply = statusSizeAndApply { - let statusFrame = CGRect(origin: CGPoint(x: resultSize.width - statusSizeAndApply.0.width - layoutConstants.text.bubbleInsets.right, y: votersFrame.maxY + statusOffset), size: statusSizeAndApply.0) - - if strongSelf.statusNode.supernode == nil { - statusSizeAndApply.1(.None) - strongSelf.statusNode.frame = statusFrame - strongSelf.addSubnode(strongSelf.statusNode) - } else { - statusSizeAndApply.1(animation) - animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: statusFrame, completion: nil) - } - } else if strongSelf.statusNode.supernode != nil { - strongSelf.statusNode.removeFromSupernode() - } - - let _ = buttonSubmitInactiveTextApply() - strongSelf.buttonSubmitInactiveTextNode.frame = buttonSubmitInactiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) - - let _ = buttonSubmitActiveTextApply() - strongSelf.buttonSubmitActiveTextNode.frame = buttonSubmitActiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) - - let _ = buttonSaveTextApply() - strongSelf.buttonSaveTextNode.frame = buttonSaveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) - - let _ = buttonViewResultsTextApply() - strongSelf.buttonViewResultsTextNode.frame = buttonViewResultsTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) - - strongSelf.updateSelection() - strongSelf.updatePollTooltipMessageState(animated: false) - - let buttonWidth: CGFloat = floor(max(strongSelf.buttonSaveTextNode.frame.width, max(strongSelf.buttonViewResultsTextNode.frame.width, strongSelf.buttonSubmitActiveTextNode.frame.width)) * 1.1) - strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonWidth) / 2.0), y: verticalOffset), size: CGSize(width: buttonWidth, height: 44.0)) - - strongSelf.updateIsTranslating(isTranslating) } - }) + for optionNode in strongSelf.optionNodes { + if !updatedOptionNodes.contains(where: { $0 === optionNode }) { + optionNode.removeFromSupernode() + } + } + strongSelf.optionNodes = updatedOptionNodes + strongSelf.updatePollOptionsInteraction(animated: animation.isAnimated) + + if let (size, apply) = addOptionSizeAndApply { + let isRequesting = item.controllerInteraction.pollActionState.pollMessageIdsInProgress[item.message.id] != nil + let addOptionNode = apply(animation.isAnimated, isRequesting) + let addOptionNodeFrame = CGRect(origin: CGPoint(x: layoutConstants.bubble.borderInset, y: verticalOffset), size: size) + if addOptionNode.supernode !== strongSelf { + strongSelf.addSubnode(addOptionNode) + } else { + animation.animator.updateFrame(layer: addOptionNode.layer, frame: addOptionNodeFrame, completion: nil) + } + addOptionNode.frame = addOptionNodeFrame + addOptionNode.isUserInteractionEnabled = !isRequesting + addOptionNode.textUpdated = { [weak self] text in + self?.updateNewOptionText(text) + } + addOptionNode.heightUpdated = { [weak self] in + self?.requestNewOptionLayoutUpdate() + } + addOptionNode.attachPressed = { [weak self] in + self?.openNewOptionAttachment() + } + addOptionNode.mediaPressed = { [weak self] in + self?.openNewOptionAttachment() + } + addOptionNode.modeSelectorPressed = { [weak self] in + self?.toggleNewOptionInputMode() + } + addOptionNode.requestSave = { [weak self] in + self?.buttonPressed() + } + addOptionNode.focusUpdated = { [weak self] focused in + guard let self else { + return + } + self.updatePollAddOptionFocused(focused) + } + strongSelf.addOptionNode = addOptionNode + verticalOffset += size.height + } else if let addOptionNode = strongSelf.addOptionNode { + strongSelf.updatePollAddOptionFocused(false) + strongSelf.addOptionNode = nil + addOptionNode.removeFromSupernode() + } + + if let poll = poll, let pendingNewOptionSubmissionText, let pendingNewOptionOptionCount = strongSelf.pendingNewOptionOptionCount, poll.options.count > pendingNewOptionOptionCount, poll.options.contains(where: { $0.text == pendingNewOptionSubmissionText }) { + strongSelf.clearNewOptionInput() + } + + if textLayout.hasRTL { + strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: resultSize.width - textFrame.size.width - textInsets.left - layoutConstants.text.bubbleInsets.right - additionalTextRightInset, y: textFrame.origin.y), size: textFrame.size) + } else { + strongSelf.textNode.textNode.frame = textFrame + } + let typeFrame = CGRect(origin: CGPoint(x: textFrame.minX, y: textFrame.maxY + titleTypeSpacing), size: typeLayout.size) + animation.animator.updateFrame(layer: strongSelf.typeNode.layer, frame: typeFrame, completion: nil) + + let deadlineTimeout = poll?.deadlineTimeout + var displayDeadlineTimer = true + var hasSelected = false + + if let poll { + if let voters = poll.results.voters { + for voter in voters { + if voter.selected { + if case .quiz = poll.kind { + displayDeadlineTimer = false + } else { + displayDeadlineTimer = !poll.isClosed + } + hasSelected = true + break + } + } + } + } + + var endDate: Int32? + if let deadlineTimeout, + message.id.namespace == Namespaces.Message.Cloud { + let startDate: Int32 + if let forwardInfo = message.forwardInfo { + startDate = forwardInfo.date + } else { + startDate = message.timestamp + } + endDate = startDate + deadlineTimeout + } + + if let poll, case .quiz = poll.kind, let deadlineTimeout, !isClosed { + let timerNode: PollBubbleTimerNode + if let current = strongSelf.timerNode { + timerNode = current + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + if displayDeadlineTimer { + timerTransition.updateAlpha(node: timerNode, alpha: 1.0) + } else { + timerTransition.updateAlpha(node: timerNode, alpha: 0.0) + } + } else { + timerNode = PollBubbleTimerNode() + strongSelf.timerNode = timerNode + strongSelf.addSubnode(timerNode) + timerNode.reachedTimeout = { + guard let strongSelf = self, + let _ = strongSelf.item else { + return + } + //item.controllerInteraction.requestMessageUpdate(item.message.id) + } + + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + if displayDeadlineTimer { + timerNode.alpha = 0.0 + timerTransition.updateAlpha(node: timerNode, alpha: 1.0) + } else { + timerNode.alpha = 0.0 + } + } + timerNode.update(regularColor: messageTheme.secondaryTextColor, proximityColor: messageTheme.scamColor, timeout: deadlineTimeout, deadlineTimestamp: endDate) + timerNode.frame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right, y: typeFrame.minY), size: CGSize()) + } else if let timerNode = strongSelf.timerNode { + strongSelf.timerNode = nil + + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in + timerNode?.removeFromSupernode() + }) + timerTransition.updateTransformScale(node: timerNode, scale: 0.1) + } + + var statusOffset: CGFloat = 0.0 + if let poll, case .poll = poll.kind, let endDate, !isClosed { + let timerNode: DeadlineTimerNode + if let current = strongSelf.deadlineTimerNode { + timerNode = current + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + if displayDeadlineTimer { + timerTransition.updateAlpha(node: timerNode, alpha: 1.0) + } else { + timerTransition.updateAlpha(node: timerNode, alpha: 0.0) + } + } else { + timerNode = DeadlineTimerNode() + strongSelf.deadlineTimerNode = timerNode + strongSelf.addSubnode(timerNode) + + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + if displayDeadlineTimer { + timerNode.alpha = 0.0 + timerTransition.updateAlpha(node: timerNode, alpha: 1.0) + } else { + timerNode.alpha = 0.0 + } + } + timerNode.update(size: resultSize, color: messageTheme.secondaryTextColor, deadlineTimeout: endDate, resultsHidden: poll.hideResultsUntilClose, strings: item.presentationData.strings) + timerNode.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset + 31.0), size: CGSize(width: resultSize.width, height: 20.0)) + statusOffset += 6.0 + } else if let timerNode = strongSelf.deadlineTimerNode { + strongSelf.deadlineTimerNode = nil + + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + timerTransition.updateAlpha(node: timerNode, alpha: 0.0, completion: { [weak timerNode] _ in + timerNode?.removeFromSupernode() + }) + timerTransition.updateTransformScale(node: timerNode, scale: 0.1) + } + + let solutionButtonSize = CGSize(width: 32.0, height: 32.0) + let solutionButtonFrame = CGRect(origin: CGPoint(x: resultSize.width - layoutConstants.text.bubbleInsets.right - solutionButtonSize.width + 5.0, y: typeFrame.minY - 16.0), size: solutionButtonSize) + strongSelf.solutionButtonNode.frame = solutionButtonFrame + + if (strongSelf.timerNode == nil || !displayDeadlineTimer), let poll = poll, case .quiz = poll.kind, let _ = poll.results.solution, (isClosed || hasSelected) { + if strongSelf.solutionButtonNode.alpha.isZero { + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 1.0) + } + strongSelf.solutionButtonNode.update(size: solutionButtonSize, theme: item.presentationData.theme.theme, incoming: incoming) + } else if !strongSelf.solutionButtonNode.alpha.isZero { + let timerTransition: ContainedViewLayoutTransition + if animation.isAnimated { + timerTransition = .animated(duration: 0.25, curve: .easeInOut) + } else { + timerTransition = .immediate + } + timerTransition.updateAlpha(node: strongSelf.solutionButtonNode, alpha: 0.0) + } + + let avatarsFrame = CGRect(origin: CGPoint(x: typeFrame.maxX + 6.0, y: typeFrame.minY + floor((typeFrame.height - MergedAvatarsNode.defaultMergedImageSize) / 2.0)), size: CGSize(width: MergedAvatarsNode.defaultMergedImageSize + MergedAvatarsNode.defaultMergedImageSpacing * 2.0, height: MergedAvatarsNode.defaultMergedImageSize)) + strongSelf.avatarsNode.frame = avatarsFrame + strongSelf.avatarsNode.updateLayout(size: avatarsFrame.size) + strongSelf.avatarsNode.update(context: item.context, peers: avatarPeers, synchronousLoad: synchronousLoad, imageSize: MergedAvatarsNode.defaultMergedImageSize, imageSpacing: MergedAvatarsNode.defaultMergedImageSpacing, borderWidth: MergedAvatarsNode.defaultBorderWidth) + strongSelf.avatarsNode.isHidden = isBotChat + let alphaTransition: ContainedViewLayoutTransition + if animation.isAnimated { + alphaTransition = .animated(duration: 0.25, curve: .easeInOut) + alphaTransition.updateAlpha(node: strongSelf.avatarsNode, alpha: avatarPeers.isEmpty ? 0.0 : 1.0) + } else { + alphaTransition = .immediate + } + + let _ = votersApply() + let votersFrame = CGRect(origin: CGPoint(x: floor((resultSize.width - votersLayout.size.width) / 2.0), y: verticalOffset + optionsVotersSpacing), size: votersLayout.size) + animation.animator.updateFrame(layer: strongSelf.votersNode.layer, frame: votersFrame, completion: nil) + + if animation.isAnimated, let previousPoll = previousPoll, let poll = poll { + if previousPoll.results.totalVoters == nil && poll.results.totalVoters != nil { + strongSelf.votersNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + } + } + + if let statusSizeAndApply = statusSizeAndApply { + let statusFrame = CGRect(origin: CGPoint(x: resultSize.width - statusSizeAndApply.0.width - layoutConstants.text.bubbleInsets.right, y: votersFrame.maxY + statusOffset), size: statusSizeAndApply.0) + + if strongSelf.statusNode.supernode == nil { + statusSizeAndApply.1(.None) + strongSelf.statusNode.frame = statusFrame + strongSelf.addSubnode(strongSelf.statusNode) + } else { + statusSizeAndApply.1(animation) + animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: statusFrame, completion: nil) + } + } else if strongSelf.statusNode.supernode != nil { + strongSelf.statusNode.removeFromSupernode() + } + + let _ = buttonSubmitInactiveTextApply() + strongSelf.buttonSubmitInactiveTextNode.frame = buttonSubmitInactiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) + + let _ = buttonSubmitActiveTextApply() + strongSelf.buttonSubmitActiveTextNode.frame = buttonSubmitActiveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) + + let _ = buttonSaveTextApply() + strongSelf.buttonSaveTextNode.frame = buttonSaveTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) + + let _ = buttonViewResultsTextApply() + strongSelf.buttonViewResultsTextNode.frame = buttonViewResultsTextFrame.offsetBy(dx: 0.0, dy: verticalOffset) + + strongSelf.updateSelection() + strongSelf.updatePollTooltipMessageState(animated: false) + + let buttonWidth: CGFloat = floor(max(strongSelf.buttonSaveTextNode.frame.width, max(strongSelf.buttonViewResultsTextNode.frame.width, strongSelf.buttonSubmitActiveTextNode.frame.width)) * 1.1) + strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: floor((resultSize.width - buttonWidth) / 2.0), y: verticalOffset), size: CGSize(width: buttonWidth, height: 44.0)) + + strongSelf.updateIsTranslating(isTranslating) + } + }) }) }) } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift index 92bd0fa3b8..f4df239aa5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTodoBubbleContentNode/Sources/ChatMessageTodoBubbleContentNode.swift @@ -769,10 +769,10 @@ private final class ChatMessageTodoItemNode: ASDisplayNode { linkColor: messageTheme.linkTextColor, baseFont: presentationData.messageFont, linkFont: presentationData.messageFont, - boldFont: presentationData.messageFont, - italicFont: presentationData.messageFont, - boldItalicFont: presentationData.messageFont, - fixedFont: presentationData.messageFont, + boldFont: presentationData.messageBoldFont, + italicFont: presentationData.messageItalicFont, + boldItalicFont: presentationData.messageBoldItalicFont, + fixedFont: presentationData.messageFixedFont, blockQuoteFont: presentationData.messageFont, underlineLinks: underlineLinks, message: message diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD index 6035b65862..d1c7e8b9d7 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/BUILD @@ -24,6 +24,7 @@ swift_library( "//submodules/TelegramUI/Components/MultiAnimationRenderer:MultiAnimationRenderer", "//submodules/TextFormat:TextFormat", "//submodules/AppBundle:AppBundle", + "//submodules/SearchBarNode:SearchBarNode", "//submodules/TelegramUI/Components/ChatInputNode:ChatInputNode", "//submodules/Components/PagerComponent:PagerComponent", "//submodules/PremiumUI:PremiumUI", diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchBarNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchBarNode.swift deleted file mode 100644 index e3e0272230..0000000000 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchBarNode.swift +++ /dev/null @@ -1,500 +0,0 @@ -import Foundation -import UIKit -import SwiftSignalKit -import AsyncDisplayKit -import Display -import TelegramPresentationData -import ActivityIndicator -import AppBundle -import FeaturedStickersScreen - -private func generateLoupeIcon(color: UIColor) -> UIImage? { - return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Loupe"), color: color) -} - -private func generateClearIcon(color: UIColor) -> UIImage? { - return generateTintedImage(image: UIImage(bundleImageName: "Components/Search Bar/Clear"), color: color) -} - -private func generateBackground(backgroundColor: UIColor, foregroundColor: UIColor) -> UIImage? { - let diameter: CGFloat = 10.0 - return generateImage(CGSize(width: diameter, height: diameter), contextGenerator: { size, context in - context.setFillColor(backgroundColor.cgColor) - context.fill(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(foregroundColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) - }, opaque: true)?.stretchableImage(withLeftCapWidth: Int(diameter / 2.0), topCapHeight: Int(diameter / 2.0)) -} - -private class PaneSearchBarTextField: UITextField { - public var didDeleteBackwardWhileEmpty: (() -> Void)? - - let placeholderLabel: ImmediateTextNode - var placeholderString: NSAttributedString? { - didSet { - self.placeholderLabel.attributedText = self.placeholderString - } - } - - let prefixLabel: ASTextNode - var prefixString: NSAttributedString? { - didSet { - self.prefixLabel.attributedText = self.prefixString - } - } - - override init(frame: CGRect) { - self.placeholderLabel = ImmediateTextNode() - self.placeholderLabel.isUserInteractionEnabled = false - self.placeholderLabel.displaysAsynchronously = false - self.placeholderLabel.maximumNumberOfLines = 1 - - self.prefixLabel = ASTextNode() - self.prefixLabel.isUserInteractionEnabled = false - self.prefixLabel.displaysAsynchronously = false - - super.init(frame: frame) - - self.addSubnode(self.placeholderLabel) - self.addSubnode(self.prefixLabel) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var keyboardAppearance: UIKeyboardAppearance { - get { - return super.keyboardAppearance - } - set { - let resigning = self.isFirstResponder - if resigning { - self.resignFirstResponder() - } - super.keyboardAppearance = newValue - if resigning { - self.becomeFirstResponder() - } - } - } - - override func textRect(forBounds bounds: CGRect) -> CGRect { - if bounds.size.width.isZero { - return CGRect(origin: CGPoint(), size: CGSize()) - } - var rect = bounds.insetBy(dx: 4.0, dy: 4.0) - - let prefixSize = self.prefixLabel.measure(bounds.size) - if !prefixSize.width.isZero { - let prefixOffset = prefixSize.width - rect.origin.x += prefixOffset - rect.size.width -= prefixOffset - } - return rect - } - - override func editingRect(forBounds bounds: CGRect) -> CGRect { - return self.textRect(forBounds: bounds) - } - - override func layoutSubviews() { - super.layoutSubviews() - - let bounds = self.bounds - if bounds.size.width.isZero { - return - } - - let constrainedSize = self.textRect(forBounds: self.bounds).size - let labelSize = self.placeholderLabel.updateLayout(constrainedSize) - self.placeholderLabel.frame = CGRect(origin: CGPoint(x: self.textRect(forBounds: bounds).minX, y: self.textRect(forBounds: bounds).minY + 4.0), size: labelSize) - - let prefixSize = self.prefixLabel.measure(constrainedSize) - let prefixBounds = bounds.insetBy(dx: 4.0, dy: 4.0) - self.prefixLabel.frame = CGRect(origin: CGPoint(x: prefixBounds.minX, y: prefixBounds.minY + 1.0), size: prefixSize) - } - - override func deleteBackward() { - if self.text == nil || self.text!.isEmpty { - self.didDeleteBackwardWhileEmpty?() - } - super.deleteBackward() - } -} - -class PaneSearchBarNode: ASDisplayNode, UITextFieldDelegate { - var cancel: (() -> Void)? - var textUpdated: ((String, String) -> Void)? - var clearPrefix: (() -> Void)? - - private let backgroundNode: ASDisplayNode - private let separatorNode: ASDisplayNode - private let textBackgroundNode: ASImageNode - private var activityIndicator: ActivityIndicator? - private let iconNode: ASImageNode - private let textField: PaneSearchBarTextField - private let clearButton: HighlightableButtonNode - private let cancelButton: HighlightableButtonNode - - var placeholderString: NSAttributedString? { - get { - return self.textField.placeholderString - } set(value) { - self.textField.placeholderString = value - } - } - - var prefixString: NSAttributedString? { - get { - return self.textField.prefixString - } set(value) { - let previous = self.prefixString - let updated: Bool - if let previous = previous, let value = value { - updated = !previous.isEqual(to: value) - } else { - updated = (previous != nil) != (value != nil) - } - if updated { - self.textField.prefixString = value - self.textField.setNeedsLayout() - self.updateIsEmpty() - } - } - } - - var text: String { - get { - return self.textField.text ?? "" - } set(value) { - if self.textField.text ?? "" != value { - self.textField.text = value - self.textFieldDidChange(self.textField) - } - } - } - - var activity: Bool = false { - didSet { - if self.activity != oldValue { - if self.activity { - if self.activityIndicator == nil, let theme = self.theme { - let activityIndicator = ActivityIndicator(type: .custom(theme.chat.inputMediaPanel.stickersSearchControlColor, 13.0, 1.0, false)) - self.activityIndicator = activityIndicator - self.addSubnode(activityIndicator) - if let (boundingSize, leftInset, rightInset) = self.validLayout { - self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate) - } - } - } else if let activityIndicator = self.activityIndicator { - self.activityIndicator = nil - activityIndicator.removeFromSupernode() - } - self.iconNode.isHidden = self.activity - } - } - } - - private var validLayout: (CGSize, CGFloat, CGFloat)? - private var theme: PresentationTheme? - - override init() { - self.backgroundNode = ASDisplayNode() - self.backgroundNode.isLayerBacked = true - - self.separatorNode = ASDisplayNode() - self.separatorNode.isLayerBacked = true - - self.textBackgroundNode = ASImageNode() - self.textBackgroundNode.isLayerBacked = false - self.textBackgroundNode.displaysAsynchronously = false - self.textBackgroundNode.displayWithoutProcessing = true - - self.iconNode = ASImageNode() - self.iconNode.isUserInteractionEnabled = false - self.iconNode.displaysAsynchronously = false - self.iconNode.displayWithoutProcessing = true - - self.textField = PaneSearchBarTextField() - self.textField.accessibilityTraits = .searchField - self.textField.autocorrectionType = .no - self.textField.returnKeyType = .search - self.textField.font = Font.regular(17.0) - - self.clearButton = HighlightableButtonNode() - self.clearButton.imageNode.displaysAsynchronously = false - self.clearButton.imageNode.displayWithoutProcessing = true - self.clearButton.displaysAsynchronously = false - self.clearButton.isHidden = true - - self.cancelButton = HighlightableButtonNode() - self.cancelButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) - self.cancelButton.displaysAsynchronously = false - - super.init() - - self.addSubnode(self.backgroundNode) - self.addSubnode(self.separatorNode) - - self.addSubnode(self.textBackgroundNode) - self.view.addSubview(self.textField) - self.addSubnode(self.iconNode) - self.addSubnode(self.clearButton) - self.addSubnode(self.cancelButton) - - self.textField.delegate = self - self.textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged) - - self.textField.didDeleteBackwardWhileEmpty = { [weak self] in - self?.clearPressed() - } - - self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) - self.clearButton.addTarget(self, action: #selector(self.clearPressed), forControlEvents: .touchUpInside) - } - - func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { - self.theme = theme - - if let activityIndicator = self.activityIndicator { - activityIndicator.type = .custom(theme.chat.inputMediaPanel.stickersSearchControlColor, 13.0, 1.0, false) - } - self.separatorNode.backgroundColor = theme.chat.inputMediaPanel.panelSeparatorColor - self.textBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 36.0, color: theme.chat.inputMediaPanel.stickersSearchBackgroundColor) - self.textField.textColor = theme.chat.inputMediaPanel.stickersSearchPrimaryColor - self.iconNode.image = generateLoupeIcon(color: theme.chat.inputMediaPanel.stickersSearchControlColor) - self.clearButton.setImage(generateClearIcon(color: theme.chat.inputMediaPanel.stickersSearchControlColor), for: []) - self.cancelButton.setAttributedTitle(NSAttributedString(string: strings.Common_Cancel, font: Font.regular(17.0), textColor: theme.chat.inputPanel.panelControlAccentColor), for: []) - self.textField.keyboardAppearance = theme.rootController.keyboardColor.keyboardAppearance - self.textField.tintColor = theme.list.itemAccentColor - - if let (boundingSize, leftInset, rightInset) = self.validLayout { - self.updateLayout(boundingSize: boundingSize, leftInset: leftInset, rightInset: rightInset, transition: .immediate) - } - } - - func updateLayout(boundingSize: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { - self.validLayout = (boundingSize, leftInset, rightInset) - - self.backgroundNode.frame = self.bounds - transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: self.bounds.size.height), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel))) - - let verticalOffset: CGFloat = -20.0 - - let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: 0.0), size: CGSize(width: boundingSize.width - leftInset - rightInset, height: boundingSize.height)) - - let cancelButtonSize = self.cancelButton.measure(CGSize(width: 100.0, height: CGFloat.infinity)) - transition.updateFrame(node: self.cancelButton, frame: CGRect(origin: CGPoint(x: contentFrame.maxX - 8.0 - cancelButtonSize.width, y: verticalOffset + 36.0), size: cancelButtonSize)) - - let textBackgroundFrame = CGRect(origin: CGPoint(x: contentFrame.minX + 8.0, y: verticalOffset + 28.0), size: CGSize(width: contentFrame.width - 16.0 - cancelButtonSize.width - 11.0, height: 36.0)) - transition.updateFrame(node: self.textBackgroundNode, frame: textBackgroundFrame) - - let textFrame = CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 27.0, y: textBackgroundFrame.minY), size: CGSize(width: max(1.0, textBackgroundFrame.size.width - 27.0 - 20.0), height: textBackgroundFrame.size.height)) - - if let iconImage = self.iconNode.image { - let iconSize = iconImage.size - transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 5.0, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - iconSize.height) / 2.0)), size: iconSize)) - } - - if let activityIndicator = self.activityIndicator { - let indicatorSize = activityIndicator.measure(CGSize(width: 32.0, height: 32.0)) - transition.updateFrame(node: activityIndicator, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.minX + 11.0, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - indicatorSize.height) / 2.0)), size: indicatorSize)) - } - - let clearSize = self.clearButton.measure(CGSize(width: 100.0, height: 100.0)) - transition.updateFrame(node: self.clearButton, frame: CGRect(origin: CGPoint(x: textBackgroundFrame.maxX - 8.0 - clearSize.width, y: textBackgroundFrame.minY + floor((textBackgroundFrame.size.height - clearSize.height) / 2.0)), size: clearSize)) - - self.textField.frame = textFrame - self.textField.layoutSubviews() - } - - @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - if let cancel = self.cancel { - cancel() - } - } - } - - func activate() { - self.textField.becomeFirstResponder() - } - - func animateIn(from node: PaneSearchBarPlaceholderNode, duration: Double, timingFunction: String, completion: @escaping () -> Void) { - let initialTextBackgroundFrame = node.view.convert(node.backgroundNode.frame, to: self.view) - - var backgroundCompleted = false - var separatorCompleted = false - var textBackgroundCompleted = false - let intermediateCompletion: () -> Void = { - if backgroundCompleted && separatorCompleted && textBackgroundCompleted { - completion() - } - } - - let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, initialTextBackgroundFrame.maxY + 8.0))) - if let fromBackgroundColor = node.backgroundColor, let toBackgroundColor = self.backgroundNode.backgroundColor { - self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.7) - } else { - self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) - } - self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in - backgroundCompleted = true - intermediateCompletion() - }) - - let initialSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, initialTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel)) - self.separatorNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) - self.separatorNode.layer.animateFrame(from: initialSeparatorFrame, to: self.separatorNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in - separatorCompleted = true - intermediateCompletion() - }) - - self.textBackgroundNode.layer.animateFrame(from: initialTextBackgroundFrame, to: self.textBackgroundNode.frame, duration: duration, timingFunction: timingFunction, completion: { _ in - textBackgroundCompleted = true - intermediateCompletion() - }) - - let labelFrame = self.textField.placeholderLabel.frame - let initialLabelNodeFrame = CGRect(origin: node.labelNode.view.convert(node.labelNode.bounds, to: self.textField.superview).origin, size: labelFrame.size) - self.textField.layer.animateFrame(from: CGRect(origin: initialLabelNodeFrame.origin.offsetBy(dx: -labelFrame.minX, dy: -labelFrame.minY), size: self.textField.frame.size), to: self.textField.frame, duration: duration, timingFunction: timingFunction) - - let iconFrame = self.iconNode.frame - let initialIconFrame = CGRect(origin: node.iconNode.view.convert(node.iconNode.bounds, to: self.iconNode.view.superview).origin, size: iconFrame.size) - self.iconNode.layer.animateFrame(from: initialIconFrame, to: self.iconNode.frame, duration: duration, timingFunction: timingFunction) - - let cancelButtonFrame = self.cancelButton.frame - self.cancelButton.layer.animatePosition(from: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: initialTextBackgroundFrame.minY + 2.0 + cancelButtonFrame.size.height / 2.0), to: self.cancelButton.layer.position, duration: duration, timingFunction: timingFunction) - node.isHidden = true - } - - func deactivate(clear: Bool = true) { - self.textField.resignFirstResponder() - if clear { - self.textField.text = nil - self.textField.placeholderLabel.isHidden = false - } - } - - func transitionOut(to node: PaneSearchBarPlaceholderNode, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { - let targetTextBackgroundFrame = node.view.convert(node.backgroundNode.view.frame, to: self.view) - - let duration: Double = 0.5 - let timingFunction = kCAMediaTimingFunctionSpring - - node.isHidden = true - self.clearButton.isHidden = true - self.textField.text = "" - - var backgroundCompleted = false - var separatorCompleted = false - var textBackgroundCompleted = false - let intermediateCompletion: () -> Void = { [weak node] in - if backgroundCompleted && separatorCompleted && textBackgroundCompleted { - completion() - node?.isHidden = false - } - } - - let targetBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: self.bounds.size.width, height: max(0.0, targetTextBackgroundFrame.maxY + 8.0))) - if let toBackgroundColor = node.backgroundColor, let fromBackgroundColor = self.backgroundNode.backgroundColor { - self.backgroundNode.layer.animate(from: fromBackgroundColor.cgColor, to: toBackgroundColor.cgColor, keyPath: "backgroundColor", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: duration * 0.5, removeOnCompletion: false) - } else { - self.backgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false) - } - self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: targetBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in - backgroundCompleted = true - intermediateCompletion() - }) - - let targetSeparatorFrame = CGRect(origin: CGPoint(x: 0.0, y: max(0.0, targetTextBackgroundFrame.maxY + 8.0)), size: CGSize(width: self.bounds.size.width, height: UIScreenPixel)) - self.separatorNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration / 2.0, removeOnCompletion: false) - self.separatorNode.layer.animateFrame(from: self.separatorNode.frame, to: targetSeparatorFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in - separatorCompleted = true - intermediateCompletion() - }) - - self.textBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false, completion: { _ in - textBackgroundCompleted = true - intermediateCompletion() - }) - - let transitionBackgroundNode = ASImageNode() - transitionBackgroundNode.isLayerBacked = true - transitionBackgroundNode.displaysAsynchronously = false - transitionBackgroundNode.displayWithoutProcessing = true - transitionBackgroundNode.image = node.backgroundNode.image - self.insertSubnode(transitionBackgroundNode, aboveSubnode: self.textBackgroundNode) - transitionBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0, removeOnCompletion: false) - transitionBackgroundNode.layer.animateFrame(from: self.textBackgroundNode.frame, to: targetTextBackgroundFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) - - let textFieldFrame = self.textField.frame - let targetLabelNodeFrame = CGRect(origin: node.labelNode.view.convert(node.labelNode.bounds, to: self.textField.superview).origin, size: textFieldFrame.size) - self.textField.layer.animateFrame(from: self.textField.frame, to: CGRect(origin: targetLabelNodeFrame.origin.offsetBy(dx: -self.textField.placeholderLabel.frame.minX, dy: -self.textField.placeholderLabel.frame.minY), size: self.textField.frame.size), duration: duration, timingFunction: timingFunction, removeOnCompletion: false) - if let snapshot = node.labelNode.layer.snapshotContentTree() { - snapshot.frame = CGRect(origin: self.textField.placeholderLabel.frame.origin, size: node.labelNode.frame.size) - self.textField.layer.addSublayer(snapshot) - snapshot.animateAlpha(from: 0.0, to: 1.0, duration: duration * 2.0 / 3.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue) - //self.textField.placeholderLabel.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 3.0 / 2.0, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false) - - } - - let iconFrame = self.iconNode.frame - let targetIconFrame = CGRect(origin: node.iconNode.view.convert(node.iconNode.bounds, to: self.iconNode.view.superview).origin, size: iconFrame.size) - self.iconNode.image = node.iconNode.image - self.iconNode.layer.animateFrame(from: self.iconNode.frame, to: targetIconFrame, duration: duration, timingFunction: timingFunction, removeOnCompletion: false) - - let cancelButtonFrame = self.cancelButton.frame - self.cancelButton.layer.animatePosition(from: self.cancelButton.layer.position, to: CGPoint(x: self.bounds.size.width + cancelButtonFrame.size.width / 2.0, y: targetTextBackgroundFrame.minY + 4.0 + cancelButtonFrame.size.height / 2.0), duration: duration, timingFunction: timingFunction, removeOnCompletion: false) - } - - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if string.range(of: "\n") != nil { - return false - } - return true - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - self.textField.resignFirstResponder() - return false - } - - @objc func textFieldDidChange(_ textField: UITextField) { - self.updateIsEmpty() - if let textUpdated = self.textUpdated { - textUpdated(textField.text ?? "", self.textField.textInputMode?.primaryLanguage ?? "") - } - } - - private func updateIsEmpty() { - let isEmpty = !(textField.text?.isEmpty ?? true) - if isEmpty != self.textField.placeholderLabel.isHidden { - self.textField.placeholderLabel.isHidden = isEmpty - } - self.clearButton.isHidden = !isEmpty && self.prefixString == nil - } - - @objc func cancelPressed() { - if let cancel = self.cancel { - cancel() - } - } - - @objc func clearPressed() { - if (self.textField.text?.isEmpty ?? true) { - if self.prefixString != nil { - self.clearPrefix?() - } - } else { - self.textField.text = "" - self.textFieldDidChange(self.textField) - } - } - - func updateQuery(_ query: String) { - self.textField.text = query - self.textFieldDidChange(self.textField) - } -} diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift index 759f4b04cf..31d8c7e2a5 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/PaneSearchContainerNode.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import AsyncDisplayKit import Display +import SearchBarNode import SwiftSignalKit import Postbox import TelegramCore @@ -16,7 +17,23 @@ import StickerPeekUI import EntityKeyboardGifContent import BatchVideoRendering -private let searchBarHeight: CGFloat = 52.0 +private let searchBarHeight: CGFloat = 76.0 +private let searchBarTopInset: CGFloat = 16.0 +private let searchBarFieldHeight: CGFloat = 44.0 + +private func paneSearchBarTheme(_ theme: PresentationTheme) -> SearchBarNodeTheme { + return SearchBarNodeTheme( + background: .clear, + separator: .clear, + inputFill: .clear, + primaryText: theme.chat.inputPanel.panelControlColor, + placeholder: theme.chat.inputPanel.inputPlaceholderColor, + inputIcon: theme.chat.inputPanel.inputControlColor, + inputClear: theme.chat.inputPanel.panelControlColor, + accent: theme.chat.inputPanel.panelControlAccentColor, + keyboard: theme.rootController.keyboardColor + ) +} public protocol PaneSearchContentNode { var ready: Signal { get } @@ -43,9 +60,10 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer private let peekBehavior: EmojiContentPeekBehavior? private let backgroundNode: ASDisplayNode - private let searchBar: PaneSearchBarNode + private let searchBar: SearchBarNode private var validLayout: CGSize? + private weak var animatedPlaceholder: PaneSearchBarPlaceholderNode? public var onCancel: (() -> Void)? @@ -69,7 +87,13 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } self.backgroundNode = ASDisplayNode() - self.searchBar = PaneSearchBarNode() + self.searchBar = SearchBarNode( + theme: paneSearchBarTheme(theme), + presentationTheme: theme, + strings: strings, + fieldStyle: .glass, + displayBackground: false + ) super.init() @@ -87,9 +111,8 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer } self.searchBar.cancel = { [weak self] in + self?.searchBar.deactivate(clear: false) cancel() - - self?.searchBar.view.endEditing(true) self?.onCancel?() } self.searchBar.activate() @@ -150,7 +173,7 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer public func updateThemeAndStrings(theme: PresentationTheme, strings: PresentationStrings) { self.backgroundNode.backgroundColor = theme.chat.inputMediaPanel.stickersBackgroundColor.withAlphaComponent(1.0) self.contentNode.updateThemeAndStrings(theme: theme, strings: strings) - self.searchBar.updateThemeAndStrings(theme: theme, strings: strings) + self.searchBar.updateThemeAndStrings(theme: paneSearchBarTheme(theme), presentationTheme: theme, strings: strings) let placeholder: String switch mode { @@ -159,11 +182,11 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer case .sticker, .trending: placeholder = strings.Stickers_Search } - self.searchBar.placeholderString = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: theme.chat.inputMediaPanel.stickersSearchPlaceholderColor) + self.searchBar.placeholderString = NSAttributedString(string: placeholder, font: Font.regular(17.0), textColor: theme.rootController.navigationSearchBar.inputPlaceholderTextColor) } public func updateQuery(_ query: String) { - self.searchBar.updateQuery(query) + self.searchBar.text = query } public func itemAt(point: CGPoint) -> (ASDisplayNode, Any)? { @@ -174,8 +197,9 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer self.validLayout = size transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size)) - transition.updateFrame(node: self.searchBar, frame: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: searchBarHeight))) - self.searchBar.updateLayout(boundingSize: CGSize(width: size.width, height: searchBarHeight), leftInset: leftInset, rightInset: rightInset, transition: transition) + let searchBarFrame = CGRect(origin: CGPoint(x: 0.0, y: searchBarTopInset), size: CGSize(width: size.width, height: searchBarFieldHeight)) + transition.updateFrame(node: self.searchBar, frame: searchBarFrame) + self.searchBar.updateLayout(boundingSize: searchBarFrame.size, leftInset: leftInset, rightInset: rightInset, transition: transition) let contentFrame = CGRect(origin: CGPoint(x: leftInset, y: searchBarHeight), size: CGSize(width: size.width - leftInset - rightInset, height: size.height - searchBarHeight)) @@ -190,6 +214,9 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer public func animateIn(from placeholder: PaneSearchBarPlaceholderNode?, anchorTop: CGPoint, anhorTopView: UIView, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { var verticalOrigin: CGFloat = anhorTopView.convert(anchorTop, to: self.view).y if let placeholder = placeholder { + self.animatedPlaceholder = placeholder + placeholder.isHidden = true + let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view) verticalOrigin = placeholderFrame.minY - 4.0 self.contentNode.animateIn(additivePosition: verticalOrigin, transition: transition) @@ -197,33 +224,65 @@ public final class PaneSearchContainerNode: ASDisplayNode, EntitySearchContainer self.contentNode.animateIn(additivePosition: 0.0, transition: transition) } + let searchBarFrame = self.searchBar.frame + let initialSearchBarFrame = CGRect(origin: CGPoint(x: searchBarFrame.minX, y: verticalOrigin), size: searchBarFrame.size) + switch transition { case let .animated(duration, curve): self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration / 2.0) - if let placeholder = placeholder { - self.searchBar.animateIn(from: placeholder, duration: duration, timingFunction: curve.timingFunction, completion: completion) - } else { - self.searchBar.alpha = 0.0 - transition.updateAlpha(node: self.searchBar, alpha: 1.0) - } + + self.searchBar.alpha = 1.0 + self.searchBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration, timingFunction: curve.timingFunction, completion: { _ in + completion() + }) + self.searchBar.layer.animateFrame(from: initialSearchBarFrame, to: searchBarFrame, duration: duration, timingFunction: curve.timingFunction) + if let size = self.validLayout { let initialBackgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin))) self.backgroundNode.layer.animateFrame(from: initialBackgroundFrame, to: self.backgroundNode.frame, duration: duration, timingFunction: curve.timingFunction) } case .immediate: + completion() break } } public func animateOut(to placeholder: PaneSearchBarPlaceholderNode, animateOutSearchBar: Bool, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { + let finish: () -> Void = { [weak self] in + placeholder.isHidden = false + if let self, self.animatedPlaceholder === placeholder { + self.animatedPlaceholder = nil + } + completion() + } + if case let .animated(duration, curve) = transition { + let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view) + let verticalOrigin = placeholderFrame.minY - 4.0 + let targetSearchBarFrame = CGRect(origin: CGPoint(x: self.searchBar.frame.minX, y: verticalOrigin), size: self.searchBar.frame.size) + if let size = self.validLayout { - let placeholderFrame = placeholder.view.convert(placeholder.bounds, to: self.view) - let verticalOrigin = placeholderFrame.minY - 4.0 self.backgroundNode.layer.animateFrame(from: self.backgroundNode.frame, to: CGRect(origin: CGPoint(x: 0.0, y: verticalOrigin), size: CGSize(width: size.width, height: max(0.0, size.height - verticalOrigin))), duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false) } + + self.searchBar.layer.animateFrame(from: self.searchBar.frame, to: targetSearchBarFrame, duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false) + if animateOutSearchBar { + self.searchBar.alpha = 0.0 + self.searchBar.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false, completion: { _ in + finish() + }) + } else { + self.searchBar.layer.animateAlpha(from: self.searchBar.alpha, to: self.searchBar.alpha, duration: duration, timingFunction: curve.timingFunction, removeOnCompletion: false, completion: { _ in + finish() + }) + } + } else { + if animateOutSearchBar { + self.searchBar.alpha = 0.0 + } + finish() } - self.searchBar.transitionOut(to: placeholder, transition: transition, completion: completion) + transition.updateAlpha(node: self.backgroundNode, alpha: 0.0) if animateOutSearchBar { transition.updateAlpha(node: self.searchBar, alpha: 0.0) diff --git a/submodules/TelegramUI/Components/ChatScheduleTimeController/BUILD b/submodules/TelegramUI/Components/ChatScheduleTimeController/BUILD index 8fa859ea8f..90488ae6ce 100644 --- a/submodules/TelegramUI/Components/ChatScheduleTimeController/BUILD +++ b/submodules/TelegramUI/Components/ChatScheduleTimeController/BUILD @@ -25,6 +25,7 @@ swift_library( "//submodules/TelegramUI/Components/ToastComponent", "//submodules/Markdown", "//submodules/UndoUI", + "//submodules/TooltipUI", "//submodules/TelegramUI/Components/LottieComponent", "//submodules/Components/MultilineTextComponent", "//submodules/Components/BundleIconComponent", @@ -36,6 +37,7 @@ swift_library( "//submodules/TelegramUI/Components/GlassBackgroundComponent", "//submodules/TelegramUI/Components/GlassBarButtonComponent", "//submodules/TelegramUI/Components/PlainButtonComponent", + "//submodules/Components/LottieAnimationComponent", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift index 782a1ff862..c1e232f39c 100644 --- a/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift +++ b/submodules/TelegramUI/Components/ChatScheduleTimeController/Sources/ChatScheduleTimeScreen.swift @@ -13,10 +13,12 @@ import SheetComponent import ButtonComponent import PlainButtonComponent import BundleIconComponent +import LottieAnimationComponent import GlassBackgroundComponent import GlassBarButtonComponent import DatePickerNode import UndoUI +import TooltipUI private let calendar = Calendar(identifier: .gregorian) @@ -66,6 +68,8 @@ private final class ChatScheduleTimeSheetContentComponent: Component { final class View: UIView { private let cancel = ComponentView() + private let silent = ComponentView() + private let title = ComponentView() private let button = ComponentView() private let secondaryButton = ComponentView() @@ -93,13 +97,15 @@ private final class ChatScheduleTimeSheetContentComponent: Component { private var monthHeight: CGFloat? + private var isSilentPosting = false + private var date: Date? private var minDate: Date? private var maxDate: Date? private var isPickingTime = false private var isPickingRepeatPeriod = false - + private var repeatPeriod: Int32? private let dateFormatter: DateFormatter @@ -144,6 +150,65 @@ private final class ChatScheduleTimeSheetContentComponent: Component { } } + private func presentSilentPostingTooltip() { + guard let component = self.component, let sourceView = self.silent.view else { + return + } + + let peerId: EnginePeer.Id? + switch component.mode { + case let .scheduledMessages(peerIdValue, _): + peerId = peerIdValue + case .reminders: + peerId = component.context.account.peerId + default: + peerId = nil + } + guard let peerId else { + return + } + + let isSilentPosting = self.isSilentPosting + let _ = (component.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let peer, let controller = self.environment?.controller() else { + return + } + + var isChannel = false + if case let .channel(channel) = peer, case .broadcast = channel.info { + isChannel = true + } + + //TODO:localize + let text: String + if case .user = peer { + if isSilentPosting { + text = "\(peer.compactDisplayTitle) will receive a silent notification" + } else { + text = "\(peer.compactDisplayTitle) will be notified" + } + } else if isChannel { + if isSilentPosting { + text = "Subscribers will receive a silent notification" + } else { + text = "Subscribers will be notified" + } + } else { + if isSilentPosting { + text = "Members will receive a silent notification" + } else { + text = "Members will be notified" + } + } + + let sourceFrame = sourceView.convert(sourceView.bounds, to: nil) + controller.present(TooltipScreen(account: component.context.account, sharedContext: component.context.sharedContext, text: .plain(text: text), style: .default, icon: .none, location: .point(sourceFrame, .bottom), shouldDismissOnTouch: { _, _ in + return .dismiss(consume: false) + }), in: .window(.root)) + }) + } + func update(component: ChatScheduleTimeSheetContentComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -184,40 +249,6 @@ private final class ChatScheduleTimeSheetContentComponent: Component { var contentHeight: CGFloat = 0.0 contentHeight += 30.0 - let barButtonSize = CGSize(width: 44.0, height: 44.0) - let cancelSize = self.cancel.update( - transition: transition, - component: AnyComponent( - GlassBarButtonComponent( - size: barButtonSize, - backgroundColor: nil, - isDark: environment.theme.overallDarkAppearance, - state: .glass, - component: AnyComponentWithIdentity(id: "close", component: AnyComponent( - BundleIconComponent( - name: "Navigation/Close", - tintColor: environment.theme.chat.inputPanel.panelControlColor - ) - )), - action: { [weak self] _ in - guard let self, let component = self.component else { - return - } - component.dismiss() - } - ) - ), - environment: {}, - containerSize: barButtonSize - ) - let cancelFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: cancelSize) - if let cancelView = self.cancel.view { - if cancelView.superview == nil { - self.addSubview(cancelView) - } - transition.setFrame(view: cancelView, frame: cancelFrame) - } - let title: String switch component.mode { case .scheduledMessages: @@ -595,7 +626,7 @@ private final class ChatScheduleTimeSheetContentComponent: Component { transition.setFrame(view: buttonView, frame: buttonFrame) } contentHeight += buttonSize.height - } else if case .scheduledMessages(true) = component.mode { + } else if case .scheduledMessages(_, true) = component.mode { contentHeight += 8.0 let buttonSize = self.secondaryButton.update( @@ -784,6 +815,87 @@ private final class ChatScheduleTimeSheetContentComponent: Component { component.externalState.repeatValueFrame = repeatValueFrame + let barButtonSize = CGSize(width: 44.0, height: 44.0) + let cancelSize = self.cancel.update( + transition: transition, + component: AnyComponent( + GlassBarButtonComponent( + size: barButtonSize, + backgroundColor: nil, + isDark: environment.theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "close", component: AnyComponent( + BundleIconComponent( + name: "Navigation/Close", + tintColor: environment.theme.chat.inputPanel.panelControlColor + ) + )), + action: { [weak self] _ in + guard let self, let component = self.component else { + return + } + component.dismiss() + } + ) + ), + environment: {}, + containerSize: barButtonSize + ) + let cancelFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: cancelSize) + if let cancelView = self.cancel.view { + if cancelView.superview == nil { + self.addSubview(cancelView) + } + transition.setFrame(view: cancelView, frame: cancelFrame) + } + + switch component.mode { + case .scheduledMessages, .reminders: + let silentSize = self.silent.update( + transition: transition, + component: AnyComponent( + GlassBarButtonComponent( + size: barButtonSize, + backgroundColor: nil, + isDark: environment.theme.overallDarkAppearance, + state: .glass, + component: AnyComponentWithIdentity(id: "silent", component: AnyComponent( + LottieAnimationComponent( + animation: LottieAnimationComponent.AnimationItem( + name: self.isSilentPosting ? "NavigationMuteOn" : "NavigationMuteOff", + mode: !transition.animation.isImmediate ? .animating(loop: false) : .still(position: .end), + range: nil, + waitForCompletion: false + ), + colors: ["__allcolors__": environment.theme.chat.inputPanel.panelControlColor], + size: CGSize(width: 30.0, height: 30.0) + ) + )), + action: { [weak self] _ in + guard let self else { + return + } + self.isSilentPosting = !self.isSilentPosting + self.state?.updated(transition: .easeInOut(duration: 0.2)) + + self.presentSilentPostingTooltip() + } + ) + ), + environment: {}, + containerSize: barButtonSize + ) + let silentFrame = CGRect(origin: CGPoint(x: availableSize.width - 16.0 - silentSize.width, y: 16.0), size: silentSize) + if let silentView = self.silent.view { + if silentView.superview == nil { + self.addSubview(silentView) + } + transition.setFrame(view: silentView, frame: silentFrame) + } + default: + break + } + return contentSize } } @@ -958,7 +1070,7 @@ private final class ChatScheduleTimeScreenComponent: Component { public class ChatScheduleTimeScreen: ViewControllerComponentContainer { public enum Mode: Equatable { - case scheduledMessages(sendWhenOnlineAvailable: Bool) + case scheduledMessages(peerId: EnginePeer.Id, sendWhenOnlineAvailable: Bool) case reminders case format case poll diff --git a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift index 30234ff2b2..eafa125640 100644 --- a/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift +++ b/submodules/TelegramUI/Components/ComposeTodoScreen/Sources/ComposeTodoScreen.swift @@ -975,6 +975,12 @@ final class ComposeTodoScreenComponent: Component { } } }, + present: { [weak self] c in + guard let controller = self?.environment?.controller() else { + return + } + controller.present(c, in: .window(.root)) + }, tag: todoItem.textFieldTag )))) diff --git a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift index 0ef201b886..09b4cb1251 100644 --- a/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift +++ b/submodules/TelegramUI/Components/ContextControllerImpl/Sources/ContextActionsContainerNode.swift @@ -424,6 +424,13 @@ final class InnerTextSelectionTipContainerNode: ASDisplayNode { self.text = self.presentationData.strings.Camera_CollageReorderingInfo self.targetSelectionIndex = nil icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") + case .deleteReaction: + self.action = nil + //TODO:localize + self.text = "Tap and hold to delete reaction." + self.targetSelectionIndex = nil + icon = nil + isUserInteractionEnabled = false } self.iconNode = ASImageNode() diff --git a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift index e048c1e86b..7736448cde 100644 --- a/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift +++ b/submodules/TelegramUI/Components/ListActionItemComponent/Sources/ListActionItemComponent.swift @@ -93,15 +93,23 @@ public final class ListActionItemComponent: Component { public enum LeftIcon: Equatable { public final class Check: Equatable { + public enum Style { + case round + case rectangle + } + + public let style: Style public let isSelected: Bool public let isEnabled: Bool public let toggle: (() -> Void)? public init( + style: Style = .round, isSelected: Bool, isEnabled: Bool = true, toggle: (() -> Void)? ) { + self.style = style self.isSelected = isSelected self.isEnabled = isEnabled self.toggle = toggle @@ -111,6 +119,9 @@ public final class ListActionItemComponent: Component { if lhs === rhs { return true } + if lhs.style != rhs.style { + return false + } if lhs.isSelected != rhs.isSelected { return false } @@ -318,12 +329,12 @@ public final class ListActionItemComponent: Component { self.action?() } - func update(size: CGSize, theme: PresentationTheme, isSelected: Bool, transition: ComponentTransition) { + func update(size: CGSize, theme: PresentationTheme, isSelected: Bool, isRectangle: Bool = false, transition: ComponentTransition) { let checkLayer: CheckLayer if let current = self.checkLayer { checkLayer = current } else { - checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check(isRectangle: false)) + checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .plain), content: .check(isRectangle: isRectangle)) self.checkLayer = checkLayer self.layer.addSublayer(checkLayer) } @@ -625,11 +636,11 @@ public final class ListActionItemComponent: Component { leftCheckView.frame = CGRect(origin: CGPoint(x: -checkSize.width, y: self.bounds.height == 0.0 ? checkFrame.minY : floor((self.bounds.height - checkSize.height) * 0.5)), size: checkFrame.size) transition.setPosition(view: leftCheckView, position: checkFrame.center) transition.setBounds(view: leftCheckView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) - leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, transition: .immediate) + leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, isRectangle: check.style == .rectangle, transition: .immediate) } else { transition.setPosition(view: leftCheckView, position: checkFrame.center) transition.setBounds(view: leftCheckView, bounds: CGRect(origin: CGPoint(), size: checkFrame.size)) - leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, transition: transition) + leftCheckView.update(size: checkFrame.size, theme: component.theme, isSelected: check.isSelected, isRectangle: check.style == .rectangle, transition: transition) } case let .custom(customLeftIcon, adjustLeftInset): var resetLeftIcon = false diff --git a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift index 8ca1510769..ab3cced9f4 100644 --- a/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift +++ b/submodules/TelegramUI/Components/ListComposePollOptionComponent/Sources/ListComposePollOptionComponent.swift @@ -130,6 +130,7 @@ public final class ListComposePollOptionComponent: Component { public let canReorder: Bool public let canAdd: Bool public let attachment: Attachment? + public let formattingAvailable: Bool public let emptyLineHandling: TextFieldComponent.EmptyLineHandling public let returnKeyType: UIReturnKeyType public let returnKeyAction: (() -> Void)? @@ -141,6 +142,7 @@ public final class ListComposePollOptionComponent: Component { public let attachAction: (() -> Void)? public let deleteAction: (() -> Void)? public let paste: ((TextFieldComponent.PasteData) -> Void)? + public let present: ((ViewController) -> Void)? public let tag: AnyObject? public init( @@ -159,6 +161,7 @@ public final class ListComposePollOptionComponent: Component { canReorder: Bool = false, canAdd: Bool = false, attachment: Attachment? = nil, + formattingAvailable: Bool = false, emptyLineHandling: TextFieldComponent.EmptyLineHandling, returnKeyType: UIReturnKeyType = .next, returnKeyAction: (() -> Void)? = nil, @@ -170,6 +173,7 @@ public final class ListComposePollOptionComponent: Component { attachAction: (() -> Void)? = nil, deleteAction: (() -> Void)? = nil, paste: ((TextFieldComponent.PasteData) -> Void)? = nil, + present: ((ViewController) -> Void)? = nil, tag: AnyObject? = nil ) { self.externalState = externalState @@ -187,6 +191,7 @@ public final class ListComposePollOptionComponent: Component { self.canReorder = canReorder self.canAdd = canAdd self.attachment = attachment + self.formattingAvailable = formattingAvailable self.emptyLineHandling = emptyLineHandling self.returnKeyType = returnKeyType self.returnKeyAction = returnKeyAction @@ -198,6 +203,7 @@ public final class ListComposePollOptionComponent: Component { self.attachAction = attachAction self.deleteAction = deleteAction self.paste = paste + self.present = present self.tag = tag } @@ -247,6 +253,9 @@ public final class ListComposePollOptionComponent: Component { if lhs.attachment != rhs.attachment { return false } + if lhs.formattingAvailable != rhs.formattingAvailable { + return false + } if lhs.emptyLineHandling != rhs.emptyLineHandling { return false } @@ -732,11 +741,15 @@ public final class ListComposePollOptionComponent: Component { enableInlineAnimations: component.enableInlineAnimations, emptyLineHandling: component.emptyLineHandling, externalHandlingForMultilinePaste: true, - formatMenuAvailability: .none, + formatMenuAvailability: component.formattingAvailable ? .available([.bold, .italic, .strikethrough, .underline, .monospace, .spoiler, .link]) : .none, returnKeyType: component.returnKeyType, lockedFormatAction: { }, - present: { _ in + present: { [weak self] c in + guard let self, let component = self.component else { + return + } + component.present?(c) }, paste: { [weak self] data in guard let self, let component = self.component else { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommentItem.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommentItem.swift index d81eca2ccd..17a891e9f2 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommentItem.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/ListItems/PeerInfoScreenCommentItem.swift @@ -167,7 +167,11 @@ private final class PeerInfoScreenCommentItemNode: PeerInfoScreenItemNode { let height = textSize.height + verticalInset * 2.0 - transition.updateFrame(node: self.textNode, frame: textFrame) + if self.textNode.frame.size != textFrame.size { + self.textNode.frame = textFrame + } else { + transition.updateFrame(node: self.textNode, frame: textFrame) + } self.activateArea.frame = CGRect(origin: CGPoint(x: safeInsets.left, y: 0.0), size: CGSize(width: width - safeInsets.left - safeInsets.right, height: height)) diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift index 732d19be82..315eb8f421 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoSettingsItems.swift @@ -375,17 +375,30 @@ func settingsEditingItems(data: PeerInfoScreenData?, state: PeerInfoState, conte items[.help]!.append(PeerInfoScreenCommentItem(id: ItemNameHelp, text: presentationData.strings.EditProfile_NameAndPhotoOrVideoHelp)) if let cachedData = data.cachedData as? CachedUserData { - items[.bio]!.append(PeerInfoScreenMultilineInputItem(id: ItemBio, text: state.updatingBio ?? (cachedData.about ?? ""), placeholder: presentationData.strings.UserInfo_About_Placeholder, textUpdated: { updatedText in + let currentBio = state.updatingBio ?? (cachedData.about ?? "") + items[.bio]!.append(PeerInfoScreenMultilineInputItem(id: ItemBio, text: currentBio, placeholder: presentationData.strings.UserInfo_About_Placeholder, textUpdated: { updatedText in interaction.updateBio(updatedText) }, action: { interaction.dismissInput() }, maxLength: Int(data.globalSettings?.userLimits.maxAboutLength ?? 70))) - items[.bio]!.append(PeerInfoScreenCommentItem(id: ItemBioHelp, text: presentationData.strings.Settings_About_PrivacyHelp, linkAction: { _ in + + + var bioPrivacyInfo = presentationData.strings.Settings_About_PrivacyHelpEmpty + if let bioPrivacy = data.globalSettings?.privacySettings?.bio, !currentBio.isEmpty { + switch bioPrivacy { + case .enableEveryone: + bioPrivacyInfo = presentationData.strings.Settings_About_PrivacyHelpEveryone + case .enableContacts: + bioPrivacyInfo = presentationData.strings.Settings_About_PrivacyHelpContacts + case .disableEveryone: + bioPrivacyInfo = presentationData.strings.Settings_About_PrivacyHelpNobody + } + } + items[.bio]!.append(PeerInfoScreenCommentItem(id: ItemBioHelp, text: bioPrivacyInfo, linkAction: { _ in interaction.openBioPrivacy() })) } - var birthday: TelegramBirthday? if let updatingBirthDate = state.updatingBirthDate { birthday = updatingBirthDate @@ -414,14 +427,22 @@ func settingsEditingItems(data: PeerInfoScreenData?, state: PeerInfoState, conte })) } - - var birthdayIsForContactsOnly = false - if let birthdayPrivacy = data.globalSettings?.privacySettings?.birthday, case .enableContacts = birthdayPrivacy { - birthdayIsForContactsOnly = true + var birthdayPrivacyInfo = "" + if let birthdayPrivacy = data.globalSettings?.privacySettings?.birthday { + switch birthdayPrivacy { + case .enableEveryone: + birthdayPrivacyInfo = presentationData.strings.Settings_Birthday_PrivacyHelpEveryone + case .enableContacts: + birthdayPrivacyInfo = presentationData.strings.Settings_Birthday_PrivacyHelpContacts + case .disableEveryone: + birthdayPrivacyInfo = presentationData.strings.Settings_Birthday_PrivacyHelpNobody + } + } + if !birthdayPrivacyInfo.isEmpty { + items[.birthday]!.append(PeerInfoScreenCommentItem(id: ItemBirthdayHelp, text: birthdayPrivacyInfo, linkAction: { _ in + interaction.openBirthdatePrivacy() + })) } - items[.birthday]!.append(PeerInfoScreenCommentItem(id: ItemBirthdayHelp, text: birthdayIsForContactsOnly ? presentationData.strings.Settings_Birthday_ContactsHelp : presentationData.strings.Settings_Birthday_Help, linkAction: { _ in - interaction.openBirthdatePrivacy() - })) if let user = data.peer as? TelegramUser { items[.info]!.append(PeerInfoScreenDisclosureItem(id: ItemPhoneNumber, label: .text(user.phone.flatMap({ formatPhoneNumber(context: context, number: $0) }) ?? ""), text: presentationData.strings.Settings_PhoneNumber, icon: PresentationResourcesSettings.recentCalls, action: { diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/Contents.json new file mode 100644 index 0000000000..da60b22b56 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "incoming_call_20.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/incoming_call_20.pdf b/submodules/TelegramUI/Images.xcassets/Chat List/CallIncomingIcon.imageset/incoming_call_20.pdf new file mode 100644 index 0000000000000000000000000000000000000000..af90dd7613ec14b9f87ae5e96f3c421d161de264 GIT binary patch literal 6022 zcma)Ac|25I+$T$=y(E&lXtR#N41+|L$TlPqV$56&vzaB6C5pC(N@Q1(7NkVTUZD^P zrR-6jBv1B`EbqNDh^OcMyzl*E=APgAo$qfy_nhA@tiGWFQc+E4S0MBVAps?T>f#{; z09slA!W5*qG2H=Wh-d|opa*f7<-$Bk0|A7snduzKnlm;7nK%NDiGzsR+CmH_9mJ7^ zwDz-UIJ_sw1YAHjB4wYH6hI{GlRAnrQ!=CJf$l^jA3Av0$J`q4XtYntoynwWAQ0Z(-iqET zid4EA0*S$35K77jWn~43LxFLU!o;x^C=6*1#vF=1$iUNyG$xTs0bo>|3zfx$ROOmC zCxC!oz^Ae3Bn~HBA!Y{uyHhqU5RT4>{KlR zUV}+wl0eun?!z%pm&E){nNyZTgcZ^t;V5qVq}U1s&=tobF{QNk5-AWik>cinR8hm@ zNF?}QSxH6<0h>f3{s!Vw7GY~4{w*I?2$sOYx&2$G130(ePzyR~?*%FUtEfdwIHHy! zbJbZC11>zr0gI?}VW1IQSvWX_7R4GzB$7Gt;7lSY3%`sNQ3j6nJ^< zN=wd}Me$^vQ@D91kaTNfI1g^+`i;*VH&~wDV4%aKpNS^%NuV1Y9hRZ5+hF2 z(4~`^#zdE3Inb-#Z-yuq^0SRUxNyU6w?h*~5|-#_2|E`%ZJ7g6WoDbI)WvYiDa)2u zNj3r`?T@nwE5s^!$m!|i*|Hn6o>jGk8>3g20g0=0Mid_EU&HLstEPzR=$nTMz6%to z<9iv%OA6$p3HCn|p9s=fFC_Kc7%xPf7a!Ww=wjfbYFiecVhm<^8DSm93fZkQ?YT%-5xA}CE5%Erls1h8@^xa_ zo42jISJv0*z*ch*JX&_I({)11k8gY6ok0`!RsrnJYeI*XztBNi%3A}nLYvQTOhP9O zTi!|Wu;xo!(->3`6n<;B<&Jt=)y-xHRqATp_m2vxZn=KBBUn*CI@#i8e0}>@&956q zg(v0CovD8ogG*2%ney-69&xs+?3#-Q(k!MrhPOuai-btXk>^`+5|2(b8Ihz;?8{y6 zyQ1~N_wq5^!OXYn+sxOvont%~_@u6mR9xr1-do6<_rn$OGXG~0gDq_FqIKFri1MCJ zlc7CB5)-GU7*nftPdn?-r1&?ogL8tV4y;MKL<>MGOt^6kHO@)B32hBD)rk*WtKjclai!|l4=vx1A_{Sw-YODs<5C1F>~^k3Wi zS4>vC)xmuIlcqm`gOQKJ@YO@z=;|YtsOlfp#o>D@#VdU)GoqaKILQXs%tTGyUZ(pT zd-~2Z%ck4dB$_$S2zTsin)?IMl$cw#DQv5uxKDW-&8x6g_80G6yU#T34R6@H*`P5w zE5+d(+GEWRWmf39J7V=s+4{$LzwbGQARn?bZnnXgV(jw|{4C0{7P#Y*QfgytBV&b0 zR!$ivp8m4zioldWp@^#dSEs?_tfaI;StpcJjwMUyN5odg*Y*m=Ez+#)g49X+ z5BVQu-=Z7$Y-teDKy)kCII&&3_}18#SxejEi(euy#czEZcPy?Xju^Kbg&>-Hq`K#O z?(&FqXStiXAI@>i5op=fvL)N8F0yy~D8WapI9~fsa?>m82Y!LE7&cwfZZ*_6hz9^>$|D=aM@JK)RAToXW0VBON zeKrf3)74_#)aIyf*ZtUibx&_ zr7%TZ8e(*74tzOsFf{qzXV;1o6<5s1%w6uOmQ7`^V~kTpUjwj~&h#aU5vvJwLQ+u(eOE{5^ru%=$+rVEOE&nRedp zz_7sKzXXs-lrG<*sDbdIMb@YNbL4}~{S5Qt{--WYZLN)ajEspHjEO)dVooP` z)H3BYbCpm0RsCsuXGwSd$RYM7zb&VbvnLZw9K71tMJ7`v6BqfP1UCd5^O|54+1I~D zvv&^pTRArj-EQt4?0zZicgXY=CMjIWxaoRYWZd{*Yc#}j zDWc6^Gc!FlWy_p6{K2*Ob$)zB?gRys8JUM7_E9l5m=_r8le1LM>c*P(aLs~tZ(si# z4mb8v-OrC)me#D-6wvhbR%l_-K78;yKJ#PQ{WA?`-kfbK?b~lViybTbr$4W~*kH`y z{J8V@it*L`tXAJ!wwY|lp|2%(e#M!5-Su-9bv&av;K94x(MsM=yj8r!=J2fdL6ghJ zw@o?Rz-nW=?CNb(E%WaWPfKLAyvFyjRFCJyz8t_f{*oqsC z!HuI+l~b7yZHjYmj#YbgdG%iG+|%{93TRtN$pLo!_wM5vfmoh%w-%-sQopF39 zpPpKg+*5ia`u_J12@kIq))sZp%VrEm%nIYpo;FXvf9LtJ)vvnCrjK0FO7;)=r7_t) ziRc(AVEnE4tN+qzkk3CzPHPi=p?`~$XB z$5s{X7oJV=1E;=4?{A+i!fZyO#*U5pjAfLnc~CDu&GBzVNvKXwIKC>39sjxGop!=R z8q3FXrG8#T2hNPeE9%brEwYHp5AYi>qV%45rTn1^NrF0H7S{*S{h zYT@{)3?CooPo?MuvORz>*3&zHV}JwzKC4;*jtg9B3taG-SdU7g(hoy-Mv!v>)k6X> ziWPL&U3}hEU$W-^Jk(-fh^1#)UUH-vWvANev*WD43Umlo!ZKd5qIRwuy7_hag7_aT zH*)R2;%6P*x`yC^@5-4N??|2+PV4F0QL!q3^6_$X;_URyc=Uei$2U%=eD}>@va4py zf2vQ7s_#4X_=+ACQ?41?ait<>c1KBj$@b!W_k!yhaqP>nJ(|wnDv^qH^svH=n*Mvo z>}3=;Rr;T+J>$^+y0#VBGAtV8+j7ol%&SDAW3sY#K$fg%Jif{3h(aw%4C9lK{5lMy zaly=_kC6PnE_TGhOmAlb;r(uqNI}JxNVyZndZT(s`RO71r9JJsTk@Zk3Xpb759HA_ zmalBF7tAWZ>mivHD(NZJyS?E#Gv&@(;xmg%vDcx$P_JXQny61yroPZu8dXYkNX%@w zFBbc16i74q>Wm)n54P#rcGSo%97jvY-XteD0{8-lWcECyb)!a=YLZ^R478Sd zTT>OFIaq4@Hdjy*ojVgKS8y|VgG=#eevMA?`!#Vbd$iB>PV*W(-8Q(|Mbzw=t{P5J z>;1OKf#y)l%m|ljipTV2iH~-991>U7*q#i^^J(gc7PcJ^%13mc)OmtOU9qz!$%oZQ z*6jWs{=AeX<(M8G!DlL?eVedqVAad~8%El4r;XgGHmmx4GK!w271^F4*II;4nO-iw zQ)7x zT4uX>#ms57j=`Gf&wWF`hU8|WXZUnyEL#3!1Aq&Rd)rl4R$r)Wq|yQzE^QMc0lw}c z;T99T0dVh~dQ=vL2_WZ2^VeKAkio6j`Mrd*iYo%f(xp(SObBfrg?ljuI1=YORaPK_ z%A(^z1^{m{^HejCK*SxOvf+!jq7vu%{i3`;`u#R9;2ZSlV#lS`D`)%t1EO1U$JA z&)+k^V&ga(aR>_4$V(tYcms-3uET&Kk4uCsrbGl=pb{u1Al5^dr2lZV^R5yri zk-ylGjY3KQ5-r3{bNClPqfja+z;(?64GDRbGecRRsiXdbrUn(!zxXidKloJ8C@9gE z@~NQJ{^V1GbM#+)YJcLXqW_euipD_u&C<21YJbX5#i&DPhoyWdCAB|kNVPw7K`CRP z&1-%wla3>jK)Mi|DaQIxHSJYZR(DWRQ&vTxm1O=k;_Md)IDG-wx7>n8947jJa44Yo VFqlwXbAp3HVN`{7?J}@1{2wCMV!8kT literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/Contents.json new file mode 100644 index 0000000000..9b101ab4da --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "outgoing_call_20.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/outgoing_call_20.pdf b/submodules/TelegramUI/Images.xcassets/Chat List/CallOutgoingIcon.imageset/outgoing_call_20.pdf new file mode 100644 index 0000000000000000000000000000000000000000..61af8fc2bb8fd45728eb9edd88c6afe52b1183e6 GIT binary patch literal 5988 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 VF_>UVbG8Smstz`&cNkln{14ac>%ag2 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Resources/Animations/NavigationMuteOff.tgs b/submodules/TelegramUI/Resources/Animations/NavigationMuteOff.tgs new file mode 100644 index 0000000000000000000000000000000000000000..0fd0c192a503e3538abc1978704508c5ed39c3ee GIT binary patch literal 1246 zcmV<41R?t$iwFqtTjFT|19fg~b#!GeYIARH0PPrEkE1s5uS9;vmOrq8ytV4}o+_Ps zZC|3OqFMGrlK=vCd#6+WduNORL*Q;#ZC5&NwF<`bJ>!}2u%F2z*%Hl!WP$|bz9w74 z1Cm!{3rh)020M&B|(NiTPz6k(OX7Fk6qQ!x< zT8kDKYb6R#52&#)-sx~gO05}><3Jk0IFADk^4k(s1-#qj!^Zcfm-Yn>dZr!E7>0RK-o+wv3%Um zks3#eQp>I~$ATB&T+^NY9~bN7tbk=#z@aPP{7@E9zg1dig)6P+mU4&V3u!w#%ee{c z5)U4)#-Ri6MsJ~0w;|5#yrGVXcKYagY^ILXk%X;%3ZQjt$+q!(H zfZ%vHh;UxO=~}^)Xax4pMMCx2etU4?)!qj_ikYaxX~3Ni-NF267n?!O zL@55W#Dsa$q)Qlm9RiaU&3##42nOA6CPih#cW_nivdGofLh{TreSb5;xQ=${I@EGw zQ`5%wGe}R159jQqx|@Jy&wriTImF% za-m{oLU&={X$K{ABj98k@o#Acac8Z4%gg$e4VkZN%hC?=B_w~&-Dmm4O9&A)_c&4t3_GJYA{|)>~`+{GFFVh$H_z6^>y8l|krh-o)4A}MhH_&dU Io+lRo04U~U!~g&Q literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Resources/Animations/NavigationMuteOn.tgs b/submodules/TelegramUI/Resources/Animations/NavigationMuteOn.tgs new file mode 100644 index 0000000000000000000000000000000000000000..7d3d18b9fe428661193be5b69957ca2a200ca7da GIT binary patch literal 1237 zcmV;`1S2IpUTa(RZ4 zN2L2q*3t|4xvEzJM6``19bm;5QsF1JYYu>Bxb_oF_$4BZr*TUI=_Mk7x?xmQc>gVuw=ddb8SC8SP5ETL^QNh)yD)KZAAn+VBzgfD2ygR*&*#cUX0B( z;Qu--?LMyy*T&Fg^05I=%gzX+CAyj9YRGmmo^(~k^OFMCQUGph~a1r;9{7- z5Ai#)zR!S7OsF^QVQ(KOJGJGU{v+reg#H~B-?REJ{T=(kN?@5>#S z?YL&`?oMRb#kmu}n8p4nryW$f%ER6F#dvKMqYetJ#e5jVO@!DGx|21}Lf3=CGYK@!_U+9*#&@-y>r(U0O@__wCy<_> zKOT#h>TpuLaEsgL?YkVeuWKF(2rSIo_f1t7O;`HXSDqhzP=Q|EK}CQL@St11TbP+c zQ;iQzUw${uarvvLk|K)j(1Ta#ue$59u1EpAUU$qfUKlvRG?i9Lk3r517jcDa2Kaf=9(CP?)_`61(^A89uPZ1+I4Mxp*S~{M zo^ddpYLN{wr%7rrSTOWR08?Nc?F?jQgr5-xLJ)Dv*?{9z8CXSCnvD72(ZeW2a;(!B zKa)zLc9ZH6S!iMJe5vYFES?h{&sh=A85_D2FL^x^pg1sRgbgf@M?Uq!CXV;1EorLL zDAqK|Y)u=g!Qxm@m7(4kQY}p)Nkx_-2rYoi$WXy;{$*5yW_W5S>8L3@0f7h22!N0= zloG0#7%;I;&-t`RiC?s>|x2uwD1a&T^vXq)zPMUdY literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 3746158718..5513e8fe1b 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -45,6 +45,7 @@ import RecaptchaEnterprise import NavigationBarImpl import ContextUI import ContextControllerImpl +import AutomationBridge #if canImport(AppCenter) import AppCenter @@ -1688,6 +1689,13 @@ private func extractAccountManagerState(records: AccountRecordsView, options: ChatAvailableMessageActionOptions) { + func presentBanMessageOptions(accountPeerId: PeerId, author: Peer, messageIds: Set, options: ChatAvailableMessageActionOptions, reaction: Bool = false) { guard let peerId = self.chatLocation.peerId else { return } @@ -325,14 +325,17 @@ extension ChatControllerImpl { initialUserBannedRights[participant.peerId] = InitialBannedRights(value: nil) } } - self.push(AdminUserActionsSheet( - context: self.context, - chatPeer: chatPeer, - peers: [RenderedChannelParticipant( - participant: participant, - peer: authorPeer._asPeer() - )], - mode: .chat( + + let mode: AdminUserActionsSheet.Mode + if reaction { + mode = .chatReaction(completion: { [weak self] result in + guard let self else { + return + } + self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights) + }) + } else { + mode = .chat( messageCount: messageIds.count, deleteAllMessageCount: deleteAllMessageCount, completion: { [weak self] result in @@ -342,6 +345,16 @@ extension ChatControllerImpl { self.applyAdminUserActionsResult(messageIds: messageIds, result: result, initialUserBannedRights: initialUserBannedRights) } ) + } + + self.push(AdminUserActionsSheet( + context: self.context, + chatPeer: chatPeer, + peers: [RenderedChannelParticipant( + participant: participant, + peer: authorPeer._asPeer() + )], + mode: mode )) }) })) diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 3c04c94421..448777c9ca 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -2232,7 +2232,7 @@ class ChatControllerNode: ASDisplayNode, ASScrollViewDelegate { additionalOffset = 80.0 } if let _ = inputPanelSize { - inputPanelHideOffset += -48.0 - additionalOffset + inputPanelHideOffset += -56.0 - additionalOffset } if let accessoryPanelSize = accessoryPanelSize { inputPanelHideOffset += -accessoryPanelSize.height - additionalOffset diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift index ee684b7ffc..4e0f3d3898 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageReactionContextMenu.swift @@ -198,7 +198,10 @@ extension ChatControllerImpl { animationCache: self.controllerInteraction!.presentationContext.animationCache, animationRenderer: self.controllerInteraction!.presentationContext.animationRenderer, message: EngineMessage(message), - reaction: value, readStats: nil, back: nil, openPeer: { peer, hasReaction in + reaction: value, + readStats: nil, + back: nil, + openPeer: { peer, hasReaction in dismissController?({ [weak self] in guard let self else { return @@ -206,6 +209,31 @@ extension ChatControllerImpl { self.openPeer(peer: peer, navigation: .default, fromMessage: MessageReference(message), fromReactionMessageId: hasReaction ? message.id : nil) }) + }, + deleteReaction: { [weak self] peer, _ in + dismissController?({ [weak self] in + guard let self, self.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + return + } + let _ = (self.context.sharedContext.chatAvailableMessageActions( + engine: self.context.engine, + accountPeerId: self.context.account.peerId, + messageIds: Set([message.id]), + keepUpdated: false + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] actions in + guard let self, !actions.options.isEmpty else { + return + } + self.presentBanMessageOptions( + accountPeerId: self.context.account.peerId, + author: peer._asPeer(), + messageIds: Set([message.id]), + options: actions.options, + reaction: true + ) + }) + }) } ))) } else { @@ -297,7 +325,9 @@ extension ChatControllerImpl { let presentationContext = self.controllerInteraction?.presentationContext let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 }) - if !packReferences.isEmpty && !premiumConfiguration.isPremiumDisabled { + if "".isEmpty { + items.tip = .deleteReaction + } else if !packReferences.isEmpty && !premiumConfiguration.isPremiumDisabled { items.tip = .animatedEmoji(text: nil, arguments: nil, file: nil, action: nil) if packReferences.count > 1 { diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift index ea565b6fe6..992b1807ae 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift @@ -92,6 +92,59 @@ func chatShareToSavedMessagesAdditionalView(_ chatController: ChatControllerImpl } extension ChatControllerImpl { +// func openMessageShareMenu(id: EngineMessage.Id) { +// guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(id), let message = messages.first else { +// return +// } +// +// let chatPresentationInterfaceState = self.presentationInterfaceState +// var warnAboutPrivate = false +// var canShareToStory = false +// if case .peer = chatPresentationInterfaceState.chatLocation, let channel = message.peers[message.id.peerId] as? TelegramChannel { +// if case .broadcast = channel.info { +// canShareToStory = true +// if let message = messages.first, message.media.contains(where: { media in +// if media is TelegramMediaContact || media is TelegramMediaPoll || media is TelegramMediaTodo { +// return true +// } else if let file = media as? TelegramMediaFile, file.isSticker || file.isAnimatedSticker || file.isVideoSticker { +// return true +// } else { +// return false +// } +// }) { +// canShareToStory = false +// } +// if message.text.containsOnlyEmoji { +// canShareToStory = false +// } +// } +// if channel.addressName == nil { +// warnAboutPrivate = true +// } +// } +// +// let _ = warnAboutPrivate +// +//// let shareScreen = self.context.sharedContext.makeShareController( +//// context: self.context, +//// subject: .messages(messages), +//// forceExternal: false, +//// shareStory: canShareToStory ? { [weak self] in +//// guard let self else { +//// return +//// } +//// Queue.mainQueue().after(0.15) { +//// let controller = self.context.sharedContext.makeStorySharingScreen(context: self.context, subject: .messages(messages), parentController: self) +//// self.push(controller) +//// } +//// } : nil, +//// enqueued: nil, +//// actionCompleted: nil +//// ) +// self.chatDisplayNode.dismissInput() +// self.push(shareScreen) +// } + func openMessageShareMenu(id: EngineMessage.Id) { guard let messages = self.chatDisplayNode.historyNode.messageGroupInCurrentHistoryView(id), let message = messages.first else { return diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 8248671fb0..2e7ff6b0c8 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -2189,6 +2189,30 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState c?.dismiss(completion: { controllerInteraction.openPeer(peer, .default, MessageReference(message), hasReaction ? .reaction : .default) }) + }, + deleteReaction: { [weak c] peer, _ in + c?.dismiss(completion: { + guard let chatController = interfaceInteraction.chatController() as? ChatControllerImpl, chatController.chatLocation.peerId?.namespace == Namespaces.Peer.CloudChannel else { + return + } + let _ = (context.sharedContext.chatAvailableMessageActions( + engine: context.engine, + accountPeerId: context.account.peerId, + messageIds: Set([message.id]), + keepUpdated: false + ) + |> deliverOnMainQueue).startStandalone(next: { actions in + guard !actions.options.isEmpty else { + return + } + chatController.presentBanMessageOptions( + accountPeerId: context.account.peerId, + author: peer._asPeer(), + messageIds: Set([message.id]), + options: actions.options + ) + }) + }) } )), tip: tip))) } else { diff --git a/submodules/TelegramUI/Sources/OpenChatMessage.swift b/submodules/TelegramUI/Sources/OpenChatMessage.swift index 0550abb689..4d9447f65b 100644 --- a/submodules/TelegramUI/Sources/OpenChatMessage.swift +++ b/submodules/TelegramUI/Sources/OpenChatMessage.swift @@ -256,11 +256,14 @@ func openChatMessageImpl(_ params: OpenChatMessageParams) -> Bool { } } + let fileReference: FileMediaReference = .message(message: MessageReference(params.message), media: file) let subject: BrowserScreen.Subject - if file.mimeType == "application/pdf" { - subject = .pdfDocument(file: .message(message: MessageReference(params.message), media: file), canShare: canShare) + if file.mimeType.contains("markdown") { + subject = .markdownDocument(file: fileReference, canShare: canShare) + } else if file.mimeType.contains("pdf") { + subject = .pdfDocument(file: fileReference, canShare: canShare) } else { - subject = .document(file: .message(message: MessageReference(params.message), media: file), canShare: canShare) + subject = .document(file: fileReference, canShare: canShare) } let controller = BrowserScreen(context: params.context, subject: subject) controller.openDocument = { [weak controller] file, canShare in diff --git a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift index 36620abb6e..0f68da6005 100644 --- a/submodules/TelegramUI/Sources/OpenResolvedUrl.swift +++ b/submodules/TelegramUI/Sources/OpenResolvedUrl.swift @@ -696,7 +696,7 @@ func openResolvedUrlImpl( present(shareController, nil) context.sharedContext.applicationBindings.dismissNativeController() } else { - let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.onlyWriteable, .excludeDisabled], selectForumThreads: true)) + let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: [.onlyWriteable, .excludeDisabled], hasFilters: true, selectForumThreads: true)) controller.peerSelected = { peer, threadId in continueWithPeer(peer.id, threadId) } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 9e0c754208..4e98f7899b 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -109,7 +109,7 @@ import EntityKeyboard private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() let tokens = Bag() - + var isEmpty: Bool { return self.tokens.isEmpty && self.subscribers.isEmpty } @@ -295,7 +295,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { init(mainWindow: Window1?, sharedContainerPath: String, basePath: String, encryptionParameters: ValueBoxEncryptionParameters, accountManager: AccountManager, appLockContext: AppLockContext, notificationController: NotificationContainerController?, applicationBindings: TelegramApplicationBindings, initialPresentationDataAndSettings: InitialPresentationDataAndSettings, networkArguments: NetworkInitializationArguments, hasInAppPurchases: Bool, rootPath: String, legacyBasePath: String?, apsNotificationToken: Signal, voipNotificationToken: Signal, firebaseSecretStream: Signal<[String: String], NoError>, setNotificationCall: @escaping (PresentationCall?) -> Void, navigateToChat: @escaping (AccountRecordId, PeerId, MessageId?, Bool) -> Void, displayUpgradeProgress: @escaping (Float?) -> Void = { _ in }, appDelegate: AppDelegate?, testingEnvironment: Bool = false) { assert(Queue.mainQueue().isCurrent()) - + precondition(!testHasInstance) testHasInstance = true diff --git a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift index 35cd855bfb..29234377d7 100644 --- a/submodules/TextFormat/Sources/StringWithAppliedEntities.swift +++ b/submodules/TextFormat/Sources/StringWithAppliedEntities.swift @@ -83,6 +83,50 @@ public func chatInputStateStringWithAppliedEntities(_ text: String, entities: [M private let syntaxHighlighter = Syntaxer() +private func isValidMessageSyntaxHighlightRange(_ range: Range, expectedLength: Int) -> Bool { + return range.lowerBound >= 0 && range.upperBound <= expectedLength && range.lowerBound < range.upperBound +} + +private func validatedCachedMessageSyntaxHighlight(_ highlight: MessageSyntaxHighlight, expectedLength: Int, language: String) -> MessageSyntaxHighlight? { + for entity in highlight.entities { + if !isValidMessageSyntaxHighlightRange(entity.range, expectedLength: expectedLength) { + return nil + } + } + return highlight +} + +private func generateMessageSyntaxHighlight(spec: CachedMessageSyntaxHighlight.Spec, theme: SyntaxterTheme) -> MessageSyntaxHighlight { + let expectedLength = (spec.text as NSString).length + guard let syntaxHighlighter else { + return MessageSyntaxHighlight(entities: []) + } + guard let highlightedString = syntaxHighlighter.syntax(spec.text, language: spec.language, theme: theme) else { + return MessageSyntaxHighlight(entities: []) + } + guard highlightedString.length == expectedLength else { + return MessageSyntaxHighlight(entities: []) + } + + var entities: [MessageSyntaxHighlight.Entity] = [] + var hasInvalidRange = false + highlightedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlightedString.length), using: { value, subRange, stop in + let range = subRange.lowerBound ..< subRange.upperBound + if !isValidMessageSyntaxHighlightRange(range, expectedLength: expectedLength) { + hasInvalidRange = true + stop.pointee = true + return + } + if let value = value as? UIColor, value != .black { + entities.append(MessageSyntaxHighlight.Entity(color: Int32(bitPattern: value.rgb), range: range)) + } + }) + if hasInvalidRange { + return MessageSyntaxHighlight(entities: []) + } + return MessageSyntaxHighlight(entities: entities) +} + public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEntity], strings: PresentationStrings? = nil, dateTimeFormat: PresentationDateTimeFormat? = nil, baseColor: UIColor, linkColor: UIColor, baseQuoteTintColor: UIColor? = nil, baseQuoteSecondaryTintColor: UIColor? = nil, baseQuoteTertiaryTintColor: UIColor? = nil, codeBlockTitleColor: UIColor? = nil, codeBlockAccentColor: UIColor? = nil, codeBlockBackgroundColor: UIColor? = nil, baseFont: UIFont, linkFont: UIFont, boldFont: UIFont, italicFont: UIFont, boldItalicFont: UIFont, fixedFont: UIFont, blockQuoteFont: UIFont, underlineLinks: Bool = true, external: Bool = false, message: Message?, entityFiles: [MediaId: TelegramMediaFile] = [:], adjustQuoteFontSize: Bool = false, cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? = nil, paragraphAlignment: NSTextAlignment? = nil) -> NSAttributedString { let baseQuoteTintColor = baseQuoteTintColor ?? baseColor @@ -399,8 +443,10 @@ public func stringWithAppliedEntities(_ text: String, entities: [MessageTextEnti let codeText = (string.string as NSString).substring(with: range) if let cachedMessageSyntaxHighlight, let entry = cachedMessageSyntaxHighlight.values[CachedMessageSyntaxHighlight.Spec(language: language, text: codeText)] { - for entity in entry.entities { - string.addAttribute(.foregroundColor, value: UIColor(rgb: UInt32(bitPattern: entity.color)), range: NSRange(location: range.location + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)) + if let validatedEntry = validatedCachedMessageSyntaxHighlight(entry, expectedLength: range.length, language: language) { + for entity in validatedEntry.entities { + string.addAttribute(.foregroundColor, value: UIColor(rgb: UInt32(bitPattern: entity.color)), range: NSRange(location: range.location + entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound)) + } } } }) @@ -588,8 +634,18 @@ public func extractMessageSyntaxHighlightSpecs(text: String, entities: [MessageT private let internalFixedCodeFont = Font.regular(17.0) public func asyncUpdateMessageSyntaxHighlight(engine: TelegramEngine, messageId: EngineMessage.Id, current: CachedMessageSyntaxHighlight?, specs: [CachedMessageSyntaxHighlight.Spec]) -> Signal { - if let current, !specs.contains(where: { current.values[$0] == nil }) { - return .complete() + if let current { + var hasMissingOrInvalidSpec = false + for spec in specs { + let expectedLength = (spec.text as NSString).length + guard let value = current.values[spec], validatedCachedMessageSyntaxHighlight(value, expectedLength: expectedLength, language: spec.language) != nil else { + hasMissingOrInvalidSpec = true + break + } + } + if !hasMissingOrInvalidSpec { + return .complete() + } } return Signal { subscriber in @@ -598,22 +654,11 @@ public func asyncUpdateMessageSyntaxHighlight(engine: TelegramEngine, messageId: let theme = SyntaxterTheme(dark: false, textColor: .black, textFont: internalFixedCodeFont, italicFont: internalFixedCodeFont, mediumFont: internalFixedCodeFont) for spec in specs { + let expectedLength = (spec.text as NSString).length if let value = current?.values[spec] { - updated[spec] = value - } else { - var entities: [MessageSyntaxHighlight.Entity] = [] - - if let syntaxHighlighter { - if let highlightedString = syntaxHighlighter.syntax(spec.text, language: spec.language, theme: theme) { - highlightedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlightedString.length), using: { value, subRange, _ in - if let value = value as? UIColor, value != .black { - entities.append(MessageSyntaxHighlight.Entity(color: Int32(bitPattern: value.rgb), range: subRange.lowerBound ..< subRange.upperBound)) - } - }) - } - } - - updated[spec] = MessageSyntaxHighlight(entities: entities) + updated[spec] = validatedCachedMessageSyntaxHighlight(value, expectedLength: expectedLength, language: spec.language) ?? MessageSyntaxHighlight(entities: []) + } else if let theme { + updated[spec] = generateMessageSyntaxHighlight(spec: spec, theme: theme) } } @@ -629,8 +674,18 @@ public func asyncUpdateMessageSyntaxHighlight(engine: TelegramEngine, messageId: } public func asyncStanaloneSyntaxHighlight(current: CachedMessageSyntaxHighlight?, specs: [CachedMessageSyntaxHighlight.Spec]) -> Signal { - if let current, !specs.contains(where: { current.values[$0] == nil }) { - return .single(current) + if let current { + var hasMissingOrInvalidSpec = false + for spec in specs { + let expectedLength = (spec.text as NSString).length + guard let value = current.values[spec], validatedCachedMessageSyntaxHighlight(value, expectedLength: expectedLength, language: spec.language) != nil else { + hasMissingOrInvalidSpec = true + break + } + } + if !hasMissingOrInvalidSpec { + return .single(current) + } } return Signal { subscriber in @@ -639,22 +694,11 @@ public func asyncStanaloneSyntaxHighlight(current: CachedMessageSyntaxHighlight? let theme = SyntaxterTheme(dark: false, textColor: .black, textFont: internalFixedCodeFont, italicFont: internalFixedCodeFont, mediumFont: internalFixedCodeFont) for spec in specs { + let expectedLength = (spec.text as NSString).length if let value = current?.values[spec] { - updated[spec] = value - } else { - var entities: [MessageSyntaxHighlight.Entity] = [] - - if let syntaxHighlighter { - if let highlightedString = syntaxHighlighter.syntax(spec.text, language: spec.language, theme: theme) { - highlightedString.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlightedString.length), using: { value, subRange, _ in - if let value = value as? UIColor, value != .black { - entities.append(MessageSyntaxHighlight.Entity(color: Int32(bitPattern: value.rgb), range: subRange.lowerBound ..< subRange.upperBound)) - } - }) - } - } - - updated[spec] = MessageSyntaxHighlight(entities: entities) + updated[spec] = validatedCachedMessageSyntaxHighlight(value, expectedLength: expectedLength, language: spec.language) ?? MessageSyntaxHighlight(entities: []) + } else if let theme { + updated[spec] = generateMessageSyntaxHighlight(spec: spec, theme: theme) } }