diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 7dd672027b..22e8723a27 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -8150,3 +8150,8 @@ Sorry for the inconvenience."; "Channel.AdminLog.MessagePreviousLinks" = "Previous links"; "ShareMenu.SelectTopic" = "Select topic"; + +"ChatList.Context.Select" = "Select"; + +"ChatList.SelectedTopics_1" = "%@ Topic Selected"; +"ChatList.SelectedTopics_any" = "%@ Topics Selected"; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index b085a0debc..7d59817fbb 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -738,6 +738,7 @@ public protocol SharedAccountContext: AnyObject { func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) func navigateToForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, messageId: EngineMessage.Id?, navigationController: NavigationController, activateInput: ChatControllerActivateInput?) -> Signal + func chatControllerForForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64) -> Signal func openStorageUsage(context: AccountContext) func openLocationScreen(context: AccountContext, messageId: MessageId, navigationController: NavigationController) func openExternalUrl(context: AccountContext, urlContext: OpenURLContext, url: String, forceExternal: Bool, presentationData: PresentationData, navigationController: NavigationController?, dismissInput: @escaping () -> Void) diff --git a/submodules/ChatListUI/Sources/ChatContextMenus.swift b/submodules/ChatListUI/Sources/ChatContextMenus.swift index be8173d91c..1c053c08f5 100644 --- a/submodules/ChatListUI/Sources/ChatContextMenus.swift +++ b/submodules/ChatListUI/Sources/ChatContextMenus.swift @@ -729,6 +729,13 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: }))) } + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Select, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Select"), color: theme.contextMenu.primaryColor) }, action: { _, f in + f(.default) + + + }))) + return .single(items) } } diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 0537a33e06..cfc6d15566 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -393,6 +393,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController super.init(context: context, navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData), mediaAccessoryPanelVisibility: .always, locationBroadcastPanelSource: .summary, groupCallPanelSource: groupCallPanelSource) self.tabBarItemContextActionType = .always + self.automaticallyControlPresentationContextLayout = false self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style @@ -489,14 +490,20 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.chatTitleDisposable = (combineLatest(queue: Queue.mainQueue(), peerView.get(), - onlineMemberCount + onlineMemberCount, + self.chatListDisplayNode.containerNode.currentItemState ) - |> deliverOnMainQueue).start(next: { [weak self] peerView, onlineMemberCount in + |> deliverOnMainQueue).start(next: { [weak self] peerView, onlineMemberCount, stateAndFilterId in guard let strongSelf = self, let chatTitleView = strongSelf.chatTitleView else { return } - chatTitleView.titleContent = .peer(peerView: peerView, customTitle: nil, onlineMemberCount: onlineMemberCount, isScheduledMessages: false, isMuted: nil) + if stateAndFilterId.state.editing && stateAndFilterId.state.selectedThreadIds.count > 0 { + chatTitleView.titleContent = .custom(strongSelf.presentationData.strings.ChatList_SelectedTopics(Int32(stateAndFilterId.state.selectedThreadIds.count)), nil, false) + } else { + chatTitleView.titleContent = .peer(peerView: peerView, customTitle: nil, onlineMemberCount: onlineMemberCount, isScheduledMessages: false, isMuted: nil) + } + strongSelf.infoReady.set(.single(true)) if let channel = peerView.peers[peerView.peerId] as? TelegramChannel, !channel.flags.contains(.isForum) { @@ -1701,12 +1708,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } let context = self.context - let peerIdsAndOptions: Signal<(ChatListSelectionOptions, Set)?, NoError> = self.chatListDisplayNode.containerNode.currentItemState - |> map { state, filterId -> (Set, Int32?)? in + let location = self.location + let peerIdsAndOptions: Signal<(ChatListSelectionOptions, Set, Set)?, NoError> = self.chatListDisplayNode.containerNode.currentItemState + |> map { state, filterId -> (Set, Set, Int32?)? in if !state.editing { return nil } - return (state.selectedPeerIds, filterId) + return (state.selectedPeerIds, state.selectedThreadIds, filterId) } |> distinctUntilChanged(isEqual: { lhs, rhs in if lhs?.0 != rhs?.0 { @@ -1715,14 +1723,26 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController if lhs?.1 != rhs?.1 { return false } + if lhs?.2 != rhs?.2 { + return false + } return true }) - |> mapToSignal { selectedPeerIdsAndFilterId -> Signal<(ChatListSelectionOptions, Set)?, NoError> in - if let (selectedPeerIds, filterId) = selectedPeerIdsAndFilterId { - return chatListSelectionOptions(context: context, peerIds: selectedPeerIds, filterId: filterId) - |> map { options -> (ChatListSelectionOptions, Set)? in - return (options, selectedPeerIds) + |> mapToSignal { selectedPeerIdsAndFilterId -> Signal<(ChatListSelectionOptions, Set, Set)?, NoError> in + if let (selectedPeerIds, selectedThreadIds, filterId) = selectedPeerIdsAndFilterId { + switch location { + case .chatList: + return chatListSelectionOptions(context: context, peerIds: selectedPeerIds, filterId: filterId) + |> map { options -> (ChatListSelectionOptions, Set, Set)? in + return (options, selectedPeerIds, selectedThreadIds) + } + case let .forum(peerId): + return forumSelectionOptions(context: context, peerId: peerId, threadIds: selectedThreadIds, canDelete: false) + |> map { options -> (ChatListSelectionOptions, Set, Set)? in + return (options, selectedPeerIds, selectedThreadIds) + } } + } else { return .single(nil) } @@ -1737,8 +1757,8 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } var toolbar: Toolbar? - if case .chatList(.root) = strongSelf.location { - if let (options, peerIds) = peerIdsAndOptions { + if let (options, peerIds, _) = peerIdsAndOptions { + if case .chatList(.root) = strongSelf.location { let leftAction: ToolbarAction switch options.read { case let .all(enabled): @@ -1765,16 +1785,23 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } toolbar = Toolbar(leftAction: leftAction, rightAction: ToolbarAction(title: presentationData.strings.Common_Delete, isEnabled: options.delete), middleAction: displayArchive ? ToolbarAction(title: presentationData.strings.ChatList_ArchiveAction, isEnabled: archiveEnabled) : nil) - } - } else { - if let (options, peerIds) = peerIdsAndOptions { + } else if case .forum = strongSelf.location { + let leftAction: ToolbarAction + switch options.read { + case .all: + leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: false) + case let .selective(enabled): + leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: enabled) + } + toolbar = Toolbar(leftAction: leftAction, rightAction: ToolbarAction(title: presentationData.strings.Common_Delete, isEnabled: options.delete), middleAction: nil) + } else { let middleAction = ToolbarAction(title: presentationData.strings.ChatList_UnarchiveAction, isEnabled: !peerIds.isEmpty) let leftAction: ToolbarAction switch options.read { - case .all: - leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: false) - case let .selective(enabled): - leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: enabled) + case .all: + leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: false) + case let .selective(enabled): + leftAction = ToolbarAction(title: presentationData.strings.ChatList_Read, isEnabled: enabled) } toolbar = Toolbar(leftAction: leftAction, rightAction: ToolbarAction(title: presentationData.strings.Common_Delete, isEnabled: options.delete), middleAction: middleAction) } @@ -1823,11 +1850,11 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController self.tabContainerNode.presentPremiumTip = { [weak self] in if let strongSelf = self { let context = strongSelf.context - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .universal(animation: "anim_reorder", scale: 0.05, colors: [:], title: nil, text: strongSelf.presentationData.strings.ChatListFolderSettings_SubscribeToMoveAll, customUndoText: strongSelf.presentationData.strings.ChatListFolderSettings_SubscribeToMoveAllAction), elevatedLayout: true, animateInAsReplacement: false, action: { action in + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .universal(animation: "anim_reorder", scale: 0.05, colors: [:], title: nil, text: strongSelf.presentationData.strings.ChatListFolderSettings_SubscribeToMoveAll, customUndoText: strongSelf.presentationData.strings.ChatListFolderSettings_SubscribeToMoveAllAction), elevatedLayout: false, position: .top, animateInAsReplacement: false, action: { action in if case .undo = action { strongSelf.push(PremiumIntroScreen(context: context, source: .folders)) } - return false }), in: .window(.root)) + return false }), in: .current) } } @@ -2462,6 +2489,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController state.editing = false state.peerIdWithRevealedOptions = nil state.selectedPeerIds.removeAll() + state.selectedThreadIds.removeAll() return state } self.chatListDisplayNode.isEditing = false diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index ddf696a788..554d9b5970 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -183,7 +183,7 @@ private final class ChatListShimmerNode: ASDisplayNode { let timestamp1: Int32 = 100000 let peers: [EnginePeer.Id: EnginePeer] = [:] let interaction = ChatListNodeInteraction(context: context, animationCache: animationCache, animationRenderer: animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in - }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in + }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() }, present: { _ in }) @@ -1298,6 +1298,10 @@ final class ChatListControllerNode: ASDisplayNode { }) } + var childrenLayout = layout + childrenLayout.intrinsicInsets = UIEdgeInsets(top: visualNavigationHeight, left: childrenLayout.intrinsicInsets.left, bottom: childrenLayout.intrinsicInsets.bottom, right: childrenLayout.intrinsicInsets.right) + self.controller?.presentationContext.containerLayoutUpdated(childrenLayout, transition: transition) + transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: layout.size)) self.containerNode.update(layout: layout, navigationBarHeight: navigationBarHeight, visualNavigationHeight: visualNavigationHeight, cleanNavigationBarHeight: cleanNavigationBarHeight, isReorderingFilters: self.isReorderingFilters, isEditing: self.isEditing, transition: transition) diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 1c78ebee4f..50c60ec833 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -1845,6 +1845,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode { }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in }, toggleArchivedFolderHiddenByDefault: { + }, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { item, node, gesture, location in guard let peerContextAction = interaction.peerContextAction else { @@ -3053,7 +3054,7 @@ private final class ChatListSearchShimmerNode: ASDisplayNode { var peers: [EnginePeer.Id: EnginePeer] = [:] peers[peer1.id] = peer1 let interaction = ChatListNodeInteraction(context: context, animationCache: animationCache, animationRenderer: animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in - }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in + }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() }, present: { _ in }) diff --git a/submodules/ChatListUI/Sources/ChatListSelection.swift b/submodules/ChatListUI/Sources/ChatListSelection.swift index ff121970ce..55ca691c4c 100644 --- a/submodules/ChatListUI/Sources/ChatListSelection.swift +++ b/submodules/ChatListUI/Sources/ChatListSelection.swift @@ -56,3 +56,20 @@ func chatListSelectionOptions(context: AccountContext, peerIds: Set, fil |> distinctUntilChanged } } + + +func forumSelectionOptions(context: AccountContext, peerId: PeerId, threadIds: Set, canDelete: Bool) -> Signal { + if threadIds.isEmpty { + return context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.PeerReadCounters(id: peerId)) + |> map { counters -> ChatListSelectionOptions in + var hasUnread = false + if counters.isUnread { + hasUnread = true + } + return ChatListSelectionOptions(read: .all(enabled: hasUnread), delete: false) + } + |> distinctUntilChanged + } else { + return .single(ChatListSelectionOptions(read: .selective(enabled: false), delete: canDelete)) + } +} diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 7cfe272b56..d236d68a55 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -333,13 +333,7 @@ private func groupReferenceRevealOptions(strings: PresentationStrings, theme: Pr private func forumRevealOptions(strings: PresentationStrings, theme: PresentationTheme, isMuted: Bool?, isClosed: Bool, isPinned: Bool, isEditing: Bool, canManage: Bool) -> [ItemListRevealOption] { var options: [ItemListRevealOption] = [] if !isEditing { - if canManage { - if isPinned { - options.append(ItemListRevealOption(key: RevealOptionKey.unpin.rawValue, title: strings.DialogList_Unpin, icon: unpinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor)) - } else { - options.append(ItemListRevealOption(key: RevealOptionKey.pin.rawValue, title: strings.DialogList_Pin, icon: pinIcon, color: theme.list.itemDisclosureActions.constructive.fillColor, textColor: theme.list.itemDisclosureActions.constructive.foregroundColor)) - } - } else if let isMuted = isMuted { + if let isMuted = isMuted { if isMuted { options.append(ItemListRevealOption(key: RevealOptionKey.unmute.rawValue, title: strings.ChatList_Unmute, icon: unmuteIcon, color: theme.list.itemDisclosureActions.neutral2.fillColor, textColor: theme.list.itemDisclosureActions.neutral2.foregroundColor)) } else { @@ -1029,14 +1023,12 @@ class ChatListItemNode: ItemListRevealOptionsItemNode { } if case let .peer(_, peer, _, _, _, _, _, _, _, _, promoInfo, _, _, _, _) = item.content { if promoInfo == nil, let mainPeer = peer.peer { - var threadId: Int64? switch item.index { case let .forum(_, _, threadIdValue, _, _): - threadId = threadIdValue + item.interaction.toggleThreadsSelection([threadIdValue], !item.selected) case .chatList: - break + item.interaction.togglePeerSelected(mainPeer, nil) } - item.interaction.togglePeerSelected(mainPeer, threadId) } } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 4152ffa1af..761189a595 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -75,6 +75,7 @@ public final class ChatListNodeInteraction { let updatePeerGrouping: (EnginePeer.Id, Bool) -> Void let togglePeerMarkedUnread: (EnginePeer.Id, Bool) -> Void let toggleArchivedFolderHiddenByDefault: () -> Void + let toggleThreadsSelection: ([Int64], Bool) -> Void let hidePsa: (EnginePeer.Id) -> Void let activateChatPreview: (ChatListItem, ASDisplayNode, ContextGesture?, CGPoint?) -> Void let present: (ViewController) -> Void @@ -109,6 +110,7 @@ public final class ChatListNodeInteraction { updatePeerGrouping: @escaping (EnginePeer.Id, Bool) -> Void, togglePeerMarkedUnread: @escaping (EnginePeer.Id, Bool) -> Void, toggleArchivedFolderHiddenByDefault: @escaping () -> Void, + toggleThreadsSelection: @escaping ([Int64], Bool) -> Void, hidePsa: @escaping (EnginePeer.Id) -> Void, activateChatPreview: @escaping (ChatListItem, ASDisplayNode, ContextGesture?, CGPoint?) -> Void, present: @escaping (ViewController) -> Void @@ -133,6 +135,7 @@ public final class ChatListNodeInteraction { self.updatePeerGrouping = updatePeerGrouping self.togglePeerMarkedUnread = togglePeerMarkedUnread self.toggleArchivedFolderHiddenByDefault = toggleArchivedFolderHiddenByDefault + self.toggleThreadsSelection = toggleThreadsSelection self.hidePsa = hidePsa self.activateChatPreview = activateChatPreview self.present = present @@ -174,8 +177,9 @@ public struct ChatListNodeState: Equatable { public var hiddenPsaPeerId: EnginePeer.Id? public var foundPeers: [(EnginePeer, EnginePeer?)] public var selectedPeerMap: [EnginePeer.Id: EnginePeer] + public var selectedThreadIds: Set - public init(presentationData: ChatListPresentationData, editing: Bool, peerIdWithRevealedOptions: EnginePeer.Id?, selectedPeerIds: Set, foundPeers: [(EnginePeer, EnginePeer?)], selectedPeerMap: [EnginePeer.Id: EnginePeer], selectedAdditionalCategoryIds: Set, peerInputActivities: ChatListNodePeerInputActivities?, pendingRemovalPeerIds: Set, pendingClearHistoryPeerIds: Set, archiveShouldBeTemporaryRevealed: Bool, hiddenPsaPeerId: EnginePeer.Id?) { + public init(presentationData: ChatListPresentationData, editing: Bool, peerIdWithRevealedOptions: EnginePeer.Id?, selectedPeerIds: Set, foundPeers: [(EnginePeer, EnginePeer?)], selectedPeerMap: [EnginePeer.Id: EnginePeer], selectedAdditionalCategoryIds: Set, peerInputActivities: ChatListNodePeerInputActivities?, pendingRemovalPeerIds: Set, pendingClearHistoryPeerIds: Set, archiveShouldBeTemporaryRevealed: Bool, hiddenPsaPeerId: EnginePeer.Id?, selectedThreadIds: Set) { self.presentationData = presentationData self.editing = editing self.peerIdWithRevealedOptions = peerIdWithRevealedOptions @@ -188,6 +192,7 @@ public struct ChatListNodeState: Equatable { self.pendingClearHistoryPeerIds = pendingClearHistoryPeerIds self.archiveShouldBeTemporaryRevealed = archiveShouldBeTemporaryRevealed self.hiddenPsaPeerId = hiddenPsaPeerId + self.selectedThreadIds = selectedThreadIds } public static func ==(lhs: ChatListNodeState, rhs: ChatListNodeState) -> Bool { @@ -227,6 +232,9 @@ public struct ChatListNodeState: Equatable { if lhs.hiddenPsaPeerId != rhs.hiddenPsaPeerId { return false } + if lhs.selectedThreadIds != rhs.selectedThreadIds { + return false + } return true } } @@ -396,6 +404,14 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL case .chatList: break } + + var isForum = false + if let peer = chatPeer, case let .channel(channel) = peer, channel.flags.contains(.isForum) { + isForum = true + if editing { + enabled = false + } + } return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( presentationData: ItemListPresentationData(theme: presentationData.theme, fontSize: presentationData.fontSize, strings: presentationData.strings), @@ -406,7 +422,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL peer: peerContent, status: status, enabled: enabled, - selection: editing ? .selectable(selected: selected) : .none, + selection: editing && !isForum ? .selectable(selected: selected) : .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: header, @@ -418,7 +434,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL nodeInteraction.peerSelected(chatPeer, nil, threadId, nil) } } - }, disabledAction: { _ in + }, disabledAction: isForum && editing ? nil : { _ in if let chatPeer = chatPeer { nodeInteraction.disabledPeerSelected(chatPeer, threadId) } @@ -556,6 +572,14 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL break } + var isForum = false + if let peer = chatPeer, case let .channel(channel) = peer, channel.flags.contains(.isForum) { + isForum = true + if editing { + enabled = false + } + } + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem( presentationData: ItemListPresentationData(theme: presentationData.theme, fontSize: presentationData.fontSize, strings: presentationData.strings), sortOrder: presentationData.nameSortOrder, @@ -565,7 +589,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL peer: peerContent, status: status, enabled: enabled, - selection: editing ? .selectable(selected: selected) : .none, + selection: editing && !isForum ? .selectable(selected: selected) : .none, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), index: nil, header: header, @@ -577,14 +601,14 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL nodeInteraction.peerSelected(chatPeer, nil, threadId, nil) } } - }, disabledAction: { _ in + }, disabledAction: isForum && editing ? nil : { _ in if let chatPeer = chatPeer { nodeInteraction.disabledPeerSelected(chatPeer, threadId) } }, animationCache: nodeInteraction.animationCache, animationRenderer: nodeInteraction.animationRenderer - ), directionHint: entry.directionHint) + ), directionHint: entry.directionHint) } case let .HoleEntry(_, theme): return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListHoleItem(theme: theme), directionHint: entry.directionHint) @@ -809,7 +833,7 @@ public final class ChatListNode: ListView { isSelecting = true } - self.currentState = ChatListNodeState(presentationData: ChatListPresentationData(theme: theme, fontSize: fontSize, strings: strings, dateTimeFormat: dateTimeFormat, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, disableAnimations: disableAnimations), editing: isSelecting, peerIdWithRevealedOptions: nil, selectedPeerIds: Set(), foundPeers: [], selectedPeerMap: [:], selectedAdditionalCategoryIds: Set(), peerInputActivities: nil, pendingRemovalPeerIds: Set(), pendingClearHistoryPeerIds: Set(), archiveShouldBeTemporaryRevealed: false, hiddenPsaPeerId: nil) + self.currentState = ChatListNodeState(presentationData: ChatListPresentationData(theme: theme, fontSize: fontSize, strings: strings, dateTimeFormat: dateTimeFormat, nameSortOrder: nameSortOrder, nameDisplayOrder: nameDisplayOrder, disableAnimations: disableAnimations), editing: isSelecting, peerIdWithRevealedOptions: nil, selectedPeerIds: Set(), foundPeers: [], selectedPeerMap: [:], selectedAdditionalCategoryIds: Set(), peerInputActivities: nil, pendingRemovalPeerIds: Set(), pendingClearHistoryPeerIds: Set(), archiveShouldBeTemporaryRevealed: false, hiddenPsaPeerId: nil, selectedThreadIds: Set()) self.statePromise = ValuePromise(self.currentState, ignoreRepeated: true) self.theme = theme @@ -1033,6 +1057,23 @@ public final class ChatListNode: ListView { }) }, toggleArchivedFolderHiddenByDefault: { [weak self] in self?.toggleArchivedFolderHiddenByDefault?() + }, toggleThreadsSelection: { [weak self] threadIds, selected in + self?.updateState { state in + var state = state + if selected { + for threadId in threadIds { + state.selectedThreadIds.insert(threadId) + } + } else { + for threadId in threadIds { + state.selectedThreadIds.remove(threadId) + } + } + return state + } + if selected && !threadIds.isEmpty { + self?.didBeginSelectingChats?() + } }, hidePsa: { [weak self] id in self?.hidePsa?(id) }, activateChatPreview: { [weak self] item, node, gesture, location in @@ -1065,7 +1106,7 @@ public final class ChatListNode: ListView { let currentRemovingPeerId = self.currentRemovingPeerId let savedMessagesPeer: Signal - if case let .peers(filter, _, _, _) = mode, filter.contains(.onlyWriteable) { + if case let .peers(filter, _, _, _) = mode, filter.contains(.onlyWriteable), case .chatList = location { savedMessagesPeer = context.account.postbox.loadedPeerWithId(context.account.peerId) |> map(Optional.init) |> map { peer in @@ -2326,7 +2367,25 @@ public final class ChatListNode: ListView { return resultPeer } + private func threadIdAtPoint(_ point: CGPoint) -> Int64? { + var resultThreadId: Int64? + self.forEachVisibleItemNode { itemNode in + if resultThreadId == nil, let itemNode = itemNode as? ListViewItemNode, itemNode.frame.contains(point) { + if let itemNode = itemNode as? ChatListItemNode, let item = itemNode.item { + switch item.content { + case let .peer(_, _, threadInfo, _, _, _, _, _, _, _, _, _, _, _, _): + resultThreadId = threadInfo?.id + default: + break + } + } + } + } + return resultThreadId + } + private var selectionPanState: (selecting: Bool, initialPeerId: EnginePeer.Id, toggledPeerIds: [[EnginePeer.Id]])? + private var threadSelectionPanState: (selecting: Bool, initialThreadId: Int64, toggledThreadIds: [[Int64]])? private var selectionScrollActivationTimer: SwiftSignalKit.Timer? private var selectionScrollDisplayLink: ConstantDisplayLinkAnimator? private var selectionScrollDelta: CGFloat? @@ -2336,15 +2395,25 @@ public final class ChatListNode: ListView { let location = recognizer.location(in: self.view) switch recognizer.state { case .began: - if let peer = self.peerAtPoint(location) { - let selecting = !self.currentState.selectedPeerIds.contains(peer.id) - self.selectionPanState = (selecting, peer.id, []) - self.interaction?.togglePeersSelection([.peer(peer)], selecting) + switch self .location { + case .chatList: + if let peer = self.peerAtPoint(location) { + let selecting = !self.currentState.selectedPeerIds.contains(peer.id) + self.selectionPanState = (selecting, peer.id, []) + self.interaction?.togglePeersSelection([.peer(peer)], selecting) + } + case .forum: + if let threadId = self.threadIdAtPoint(location) { + let selecting = !self.currentState.selectedThreadIds.contains(threadId) + self.threadSelectionPanState = (selecting, threadId, []) + self.interaction?.toggleThreadsSelection([threadId], selecting) + } } case .changed: self.handlePanSelection(location: location) self.selectionLastLocation = location case .ended, .failed, .cancelled: + self.threadSelectionPanState = nil self.selectionPanState = nil self.selectionScrollDisplayLink = nil self.selectionScrollActivationTimer?.invalidate() @@ -2367,66 +2436,111 @@ public final class ChatListNode: ListView { location.y = self.frame.height - self.insets.bottom - 5.0 } - if let state = self.selectionPanState { - if let peer = self.peerAtPoint(location) { - if peer.id == state.initialPeerId { - if !state.toggledPeerIds.isEmpty { - self.interaction?.togglePeersSelection(state.toggledPeerIds.flatMap { $0.compactMap({ .peerId($0) }) }, !state.selecting) - self.selectionPanState = (state.selecting, state.initialPeerId, []) - } - } else if state.toggledPeerIds.last?.first != peer.id { - var updatedToggledPeerIds: [[EnginePeer.Id]] = [] - var previouslyToggled = false - for i in (0 ..< state.toggledPeerIds.count) { - if let peerId = state.toggledPeerIds[i].first { - if peerId == peer.id { - previouslyToggled = true - updatedToggledPeerIds = Array(state.toggledPeerIds.prefix(i + 1)) - - let peerIdsToToggle = Array(state.toggledPeerIds.suffix(state.toggledPeerIds.count - i - 1)).flatMap { $0 } - self.interaction?.togglePeersSelection(peerIdsToToggle.compactMap { .peerId($0) }, !state.selecting) - break + var hasState = false + switch self.location { + case .chatList: + if let state = self.selectionPanState { + hasState = true + if let peer = self.peerAtPoint(location) { + if peer.id == state.initialPeerId { + if !state.toggledPeerIds.isEmpty { + self.interaction?.togglePeersSelection(state.toggledPeerIds.flatMap { $0.compactMap({ .peerId($0) }) }, !state.selecting) + self.selectionPanState = (state.selecting, state.initialPeerId, []) + } + } else if state.toggledPeerIds.last?.first != peer.id { + var updatedToggledPeerIds: [[EnginePeer.Id]] = [] + var previouslyToggled = false + for i in (0 ..< state.toggledPeerIds.count) { + if let peerId = state.toggledPeerIds[i].first { + if peerId == peer.id { + previouslyToggled = true + updatedToggledPeerIds = Array(state.toggledPeerIds.prefix(i + 1)) + + let peerIdsToToggle = Array(state.toggledPeerIds.suffix(state.toggledPeerIds.count - i - 1)).flatMap { $0 } + self.interaction?.togglePeersSelection(peerIdsToToggle.compactMap { .peerId($0) }, !state.selecting) + break + } } } - } - - if !previouslyToggled { - updatedToggledPeerIds = state.toggledPeerIds - let isSelected = self.currentState.selectedPeerIds.contains(peer.id) - if state.selecting != isSelected { - updatedToggledPeerIds.append([peer.id]) - self.interaction?.togglePeersSelection([.peer(peer)], state.selecting) + + if !previouslyToggled { + updatedToggledPeerIds = state.toggledPeerIds + let isSelected = self.currentState.selectedPeerIds.contains(peer.id) + if state.selecting != isSelected { + updatedToggledPeerIds.append([peer.id]) + self.interaction?.togglePeersSelection([.peer(peer)], state.selecting) + } } + + self.selectionPanState = (state.selecting, state.initialPeerId, updatedToggledPeerIds) } - - self.selectionPanState = (state.selecting, state.initialPeerId, updatedToggledPeerIds) } } - - let scrollingAreaHeight: CGFloat = 50.0 - if location.y < scrollingAreaHeight + self.insets.top || location.y > self.frame.height - scrollingAreaHeight - self.insets.bottom { - if location.y < self.frame.height / 2.0 { - self.selectionScrollDelta = (scrollingAreaHeight - (location.y - self.insets.top)) / scrollingAreaHeight - } else { - self.selectionScrollDelta = -(scrollingAreaHeight - min(scrollingAreaHeight, max(0.0, (self.frame.height - self.insets.bottom - location.y)))) / scrollingAreaHeight - } - if let displayLink = self.selectionScrollDisplayLink { - displayLink.isPaused = false - } else { - if let _ = self.selectionScrollActivationTimer { - } else { - let timer = SwiftSignalKit.Timer(timeout: 0.45, repeat: false, completion: { [weak self] in - self?.setupSelectionScrolling() - }, queue: .mainQueue()) - timer.start() - self.selectionScrollActivationTimer = timer + case .forum: + if let state = self.threadSelectionPanState { + hasState = true + if let threadId = self.threadIdAtPoint(location) { + if threadId == state.initialThreadId { + if !state.toggledThreadIds.isEmpty { + self.interaction?.toggleThreadsSelection(Array(state.toggledThreadIds.joined()), !state.selecting) + self.threadSelectionPanState = (state.selecting, state.initialThreadId, []) + } + } else if state.toggledThreadIds.last?.first != threadId { + var updatedToggledThreadIds: [[Int64]] = [] + var previouslyToggled = false + for i in (0 ..< state.toggledThreadIds.count) { + if let toggledThreadId = state.toggledThreadIds[i].first { + if toggledThreadId == threadId { + previouslyToggled = true + updatedToggledThreadIds = Array(state.toggledThreadIds.prefix(i + 1)) + + let threadIdsToToggle = Array(state.toggledThreadIds.suffix(state.toggledThreadIds.count - i - 1)).flatMap { $0 } + self.interaction?.toggleThreadsSelection(threadIdsToToggle.compactMap { $0 }, !state.selecting) + break + } + } + } + + if !previouslyToggled { + updatedToggledThreadIds = state.toggledThreadIds + let isSelected = self.currentState.selectedThreadIds.contains(threadId) + if state.selecting != isSelected { + updatedToggledThreadIds.append([threadId]) + self.interaction?.toggleThreadsSelection([threadId], state.selecting) + } + } + + self.threadSelectionPanState = (state.selecting, state.initialThreadId, updatedToggledThreadIds) } } + } + } + guard hasState else { + return + } + let scrollingAreaHeight: CGFloat = 50.0 + if location.y < scrollingAreaHeight + self.insets.top || location.y > self.frame.height - scrollingAreaHeight - self.insets.bottom { + if location.y < self.frame.height / 2.0 { + self.selectionScrollDelta = (scrollingAreaHeight - (location.y - self.insets.top)) / scrollingAreaHeight } else { - self.selectionScrollDisplayLink?.isPaused = true - self.selectionScrollActivationTimer?.invalidate() - self.selectionScrollActivationTimer = nil + self.selectionScrollDelta = -(scrollingAreaHeight - min(scrollingAreaHeight, max(0.0, (self.frame.height - self.insets.bottom - location.y)))) / scrollingAreaHeight } + if let displayLink = self.selectionScrollDisplayLink { + displayLink.isPaused = false + } else { + if let _ = self.selectionScrollActivationTimer { + } else { + let timer = SwiftSignalKit.Timer(timeout: 0.45, repeat: false, completion: { [weak self] in + self?.setupSelectionScrolling() + }, queue: .mainQueue()) + timer.start() + self.selectionScrollActivationTimer = timer + } + } + } else { + self.selectionScrollDisplayLink?.isPaused = true + self.selectionScrollActivationTimer?.invalidate() + self.selectionScrollActivationTimer = nil } } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift index 7bf30c2f05..ce291f0afc 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeEntries.swift @@ -369,10 +369,6 @@ func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState if let peerId { hasActiveRevealControls = peerId == state.peerIdWithRevealedOptions } - var isSelected = false - if let peerId { - isSelected = state.selectedPeerIds.contains(peerId) - } var inputActivities: [(EnginePeer, PeerInputActivity)]? if let peerId { inputActivities = state.peerInputActivities?.activities[peerId] @@ -385,6 +381,13 @@ func chatListNodeEntriesForView(_ view: EngineChatList, state: ChatListNodeState default: break } + + var isSelected = false + if threadId != 0 { + isSelected = state.selectedThreadIds.contains(threadId) + } else if let peerId { + isSelected = state.selectedPeerIds.contains(peerId) + } result.append(.PeerEntry(index: offsetPinnedIndex(entry.index, offset: pinnedIndexOffset), presentationData: state.presentationData, messages: updatedMessages, readState: updatedCombinedReadState, isRemovedFromTotalUnreadCount: entry.isMuted, draftState: draftState, peer: entry.renderedPeer, threadInfo: entry.threadData.flatMap { ChatListItemContent.ThreadInfo(id: threadId, info: $0.info, isOwner: $0.isOwnedByMe, isClosed: $0.isClosed) }, presence: entry.presence, hasUnseenMentions: entry.hasUnseenMentions, hasUnseenReactions: entry.hasUnseenReactions, editing: state.editing, hasActiveRevealControls: hasActiveRevealControls, selected: isSelected, inputActivities: inputActivities, promoInfo: nil, hasFailedMessages: entry.hasFailed, isContact: entry.isContact, forumTopicData: entry.forumTopicData)) } diff --git a/submodules/ChatMessageBackground/Sources/ChatMessageBackground.swift b/submodules/ChatMessageBackground/Sources/ChatMessageBackground.swift index 2adfdea6dc..b35ac1aa3e 100644 --- a/submodules/ChatMessageBackground/Sources/ChatMessageBackground.swift +++ b/submodules/ChatMessageBackground/Sources/ChatMessageBackground.swift @@ -60,8 +60,8 @@ public enum ChatMessageBackgroundType: Equatable { } public class ChatMessageBackground: ASDisplayNode { - public weak var backdropNode: ASDisplayNode? - + public weak var backdropNode: ChatMessageBubbleBackdrop? + public private(set) var type: ChatMessageBackgroundType? private var currentHighlighted: Bool? private var hasWallpaper: Bool? @@ -85,7 +85,7 @@ public class ChatMessageBackground: ASDisplayNode { self.outlineImageNode.displayWithoutProcessing = true super.init() - + self.isUserInteractionEnabled = false self.addSubnode(self.outlineImageNode) self.addSubnode(self.imageNode) @@ -259,9 +259,9 @@ public class ChatMessageBackground: ASDisplayNode { } } } - + self.imageNode.image = image - if highlighted && maskMode, let backdropNode = self.backdropNode { + if highlighted && maskMode, let backdropNode = self.backdropNode, backdropNode.hasImage { self.imageNode.layer.compositingFilter = "overlayBlendMode" self.imageNode.alpha = 1.0 @@ -435,11 +435,17 @@ public final class ChatMessageBubbleBackdrop: ASDisplayNode { private var essentialGraphics: PrincipalThemeEssentialGraphics? private weak var backgroundNode: WallpaperBackgroundNode? - private var maskView: UIImageView? + public var maskView: UIImageView? private var fixedMaskMode: Bool? private var absolutePosition: (CGRect, CGSize)? + public var overrideMask: Bool = false { + didSet { + self.maskView?.image = nil + } + } + public var hasImage: Bool { return self.backgroundContent != nil } @@ -475,7 +481,7 @@ public final class ChatMessageBubbleBackdrop: ASDisplayNode { self.setType(type: currentType, theme: theme, essentialGraphics: essentialGraphics, maskMode: maskMode, backgroundNode: backgroundNode) } } - + public func setType(type: ChatMessageBackgroundType, theme: ChatPresentationThemeData, essentialGraphics: PrincipalThemeEssentialGraphics, maskMode inputMaskMode: Bool, backgroundNode: WallpaperBackgroundNode?) { let maskMode = self.fixedMaskMode ?? inputMaskMode @@ -555,11 +561,11 @@ public final class ChatMessageBubbleBackdrop: ASDisplayNode { } if let maskView = self.maskView { - maskView.image = bubbleMaskForType(type, graphics: essentialGraphics) + maskView.image = self.overrideMask ? nil : bubbleMaskForType(type, graphics: essentialGraphics) } } } - + public func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition = .immediate) { self.absolutePosition = (rect, containerSize) if let backgroundContent = self.backgroundContent { diff --git a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift index f76eabe142..b41df530a4 100644 --- a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift +++ b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift @@ -970,7 +970,14 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { } else if peer.isDeleted { overrideImage = .deletedIcon } - strongSelf.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, synchronousLoad: synchronousLoads) + let clipStyle: AvatarNodeClipStyle + if case let .channel(channel) = peer, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + + strongSelf.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: clipStyle, synchronousLoad: synchronousLoads) } case let .deviceContact(_, contact): let letters: [String] diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index 9246b9bad3..b767121111 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -423,6 +423,21 @@ public extension ContainedViewLayoutTransition { } } + func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { + switch self { + case .immediate: + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + layer.animatePosition(from: fromValue, to: toValue, duration: duration, timingFunction: curve.timingFunction, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: { result in + if let completion = completion { + completion(result) + } + }) + } + } + func animatePosition(node: ASDisplayNode, from position: CGPoint, completion: ((Bool) -> Void)? = nil) { switch self { case .immediate: @@ -1455,6 +1470,31 @@ public extension ContainedViewLayoutTransition { }) } } + + func updatePath(layer: CAShapeLayer, path: CGPath, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + if layer.path == path { + completion?(true) + return + } + + switch self { + case .immediate: + layer.removeAnimation(forKey: "path") + layer.path = path + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let fromPath = layer.path + layer.path = path + layer.animate(from: fromPath, to: path, keyPath: "path", timingFunction: curve.timingFunction, duration: duration, delay: delay, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: true, additive: false, completion: { + result in + if let completion = completion { + completion(result) + } + }) + } + } } public struct CombinedTransition { @@ -1600,6 +1640,7 @@ public protocol ControlledTransitionAnimator: AnyObject { func updateScale(layer: CALayer, scale: CGFloat, completion: ((Bool) -> Void)?) func animateScale(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat, completion: ((Bool) -> Void)?) func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) + func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, completion: ((Bool) -> Void)?) func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) func updateFrame(layer: CALayer, frame: CGRect, completion: ((Bool) -> Void)?) func updateCornerRadius(layer: CALayer, cornerRadius: CGFloat, completion: ((Bool) -> Void)?) @@ -1752,14 +1793,14 @@ extension CGRect: AnyValueProviding { final class ControlledTransitionProperty { final class AnyValue: Equatable, CustomStringConvertible { let value: Any - let nsValue: NSValue + let nsValue: Any let stringValue: () -> String let isEqual: (AnyValue) -> Bool let interpolate: (AnyValue, CGFloat) -> AnyValue init( value: Any, - nsValue: NSValue, + nsValue: Any, stringValue: @escaping () -> String, isEqual: @escaping (AnyValue) -> Bool, interpolate: @escaping (AnyValue, CGFloat) -> AnyValue @@ -1951,6 +1992,16 @@ public final class ControlledTransition { )) } + public func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, completion: ((Bool) -> Void)?) { + self.add(animation: ControlledTransitionProperty( + layer: layer, + path: "position", + fromValue: fromValue, + toValue: toValue, + completion: completion + )) + } + public func updatePosition(layer: CALayer, position: CGPoint, completion: ((Bool) -> Void)?) { if layer.position == position { return @@ -2059,6 +2110,10 @@ public final class ControlledTransition { self.transition.updatePosition(layer: layer, position: position, completion: completion) } + public func animatePosition(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, completion: ((Bool) -> Void)?) { + self.transition.animatePosition(layer: layer, from: fromValue, to: toValue, completion: completion) + } + public func updateBounds(layer: CALayer, bounds: CGRect, completion: ((Bool) -> Void)?) { self.transition.updateBounds(layer: layer, bounds: bounds, completion: completion) } diff --git a/submodules/Display/Source/GenerateImage.swift b/submodules/Display/Source/GenerateImage.swift index 5304bfa600..397b4f2a64 100644 --- a/submodules/Display/Source/GenerateImage.swift +++ b/submodules/Display/Source/GenerateImage.swift @@ -147,6 +147,41 @@ public func generateFilledCircleImage(diameter: CGFloat, color: UIColor?, stroke }) } +public func generateFilledRoundedRectImage(size: CGSize, cornerRadius: CGFloat, color: UIColor?, strokeColor: UIColor? = nil, strokeWidth: CGFloat? = nil, backgroundColor: UIColor? = nil) -> UIImage? { + return generateImage(CGSize(width: size.width, height: size.height), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + if let backgroundColor = backgroundColor { + context.setFillColor(backgroundColor.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + } + + if let strokeColor = strokeColor, let strokeWidth = strokeWidth { + context.setFillColor(strokeColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: size)) + + if let color = color { + context.setFillColor(color.cgColor) + } else { + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + } + let path = CGPath(roundedRect: CGRect(origin: CGPoint(x: strokeWidth, y: strokeWidth), size: CGSize(width: size.width - strokeWidth * 2.0, height: size.height - strokeWidth * 2.0)), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + context.addPath(path) + context.fillPath() + } else { + if let color = color { + context.setFillColor(color.cgColor) + } else { + context.setFillColor(UIColor.clear.cgColor) + context.setBlendMode(.copy) + } + let path = CGPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + context.addPath(path) + context.fillPath() + } + }) +} + public func generateAdjustedStretchableFilledCircleImage(diameter: CGFloat, color: UIColor) -> UIImage? { let corner: CGFloat = diameter / 2.0 return generateImage(CGSize(width: diameter + 2.0, height: diameter + 2.0), contextGenerator: { size, context in diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index c636de0c32..33b855f8e0 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -1600,9 +1600,13 @@ open class ListView: ASDisplayNode, UIScrollViewAccessibilityDelegate, UIGesture self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) self.lastContentOffset = CGPoint(x: 0.0, y: infiniteScrollSize * 2.0 - bottomItemEdge) self.scroller.contentOffset = self.lastContentOffset - } - else - { + } else if self.itemNodes.isEmpty { + self.scroller.contentSize = self.visibleSize + if self.lastContentOffset.y == infiniteScrollSize && self.scroller.contentOffset.y.isZero { + self.scroller.contentOffset = .zero + self.lastContentOffset = .zero + } + } else { self.scroller.contentSize = CGSize(width: self.visibleSize.width, height: infiniteScrollSize * 2.0) if abs(self.scroller.contentOffset.y - infiniteScrollSize) > infiniteScrollSize / 2.0 { self.lastContentOffset = CGPoint(x: 0.0, y: infiniteScrollSize) diff --git a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift index efa5552cb9..ba06f978ba 100644 --- a/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift +++ b/submodules/HashtagSearchUI/Sources/HashtagSearchController.swift @@ -84,6 +84,7 @@ public final class HashtagSearchController: TelegramBaseController { }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in }, toggleArchivedFolderHiddenByDefault: { + }, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() diff --git a/submodules/SSignalKit/SwiftSignalKit/Source/Signal_Combine.swift b/submodules/SSignalKit/SwiftSignalKit/Source/Signal_Combine.swift index cd4537ffa6..cbc3a78c20 100644 --- a/submodules/SSignalKit/SwiftSignalKit/Source/Signal_Combine.swift +++ b/submodules/SSignalKit/SwiftSignalKit/Source/Signal_Combine.swift @@ -190,6 +190,12 @@ public func combineLatest(queue: Queue? = nil, _ s1: Signal, _ s2: Signal, _ s3: Signal, _ s4: Signal, _ s5: Signal, _ s6: Signal, _ s7: Signal, _ s8: Signal, _ s9: Signal, _ s10: Signal, _ s11: Signal, _ s12: Signal, _ s13: Signal, _ s14: Signal, _ s15: Signal, _ s16: Signal, _ s17: Signal, _ s18: Signal) -> Signal<(T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16, T17, T18), E> { + return combineLatestAny([signalOfAny(s1), signalOfAny(s2), signalOfAny(s3), signalOfAny(s4), signalOfAny(s5), signalOfAny(s6), signalOfAny(s7), signalOfAny(s8), signalOfAny(s9), signalOfAny(s10), signalOfAny(s11), signalOfAny(s12), signalOfAny(s13), signalOfAny(s14), signalOfAny(s15), signalOfAny(s16), signalOfAny(s17), signalOfAny(s18)], combine: { values in + return (values[0] as! T1, values[1] as! T2, values[2] as! T3, values[3] as! T4, values[4] as! T5, values[5] as! T6, values[6] as! T7, values[7] as! T8, values[8] as! T9, values[9] as! T10, values[10] as! T11, values[11] as! T12, values[12] as! T13, values[13] as! T14, values[14] as! T15, values[15] as! T16, values[16] as! T17, values[17] as! T18) + }, initialValues: [:], queue: queue) +} + public func combineLatest(queue: Queue? = nil, _ signals: [Signal]) -> Signal<[T], E> { if signals.count == 0 { return single([T](), E.self) diff --git a/submodules/SearchBarNode/Sources/SearchBarNode.swift b/submodules/SearchBarNode/Sources/SearchBarNode.swift index 76d53fd934..8eb1c5dba0 100644 --- a/submodules/SearchBarNode/Sources/SearchBarNode.swift +++ b/submodules/SearchBarNode/Sources/SearchBarNode.swift @@ -1216,13 +1216,21 @@ public class SearchBarNode: ASDisplayNode, UITextFieldDelegate { } private func updateIsEmpty(animated: Bool = false) { - let isEmpty = (self.textField.text?.isEmpty ?? true) && self.tokens.isEmpty + let textIsEmpty = (self.textField.text?.isEmpty ?? true) + let isEmpty = textIsEmpty && self.tokens.isEmpty let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.3, curve: .spring) : .immediate let placeholderTransition = !isEmpty ? .immediate : transition placeholderTransition.updateAlpha(node: self.textField.placeholderLabel, alpha: isEmpty ? 1.0 : 0.0) - let clearIsHidden = isEmpty && self.prefixString == nil + var tokensEmpty = true + for token in self.tokens { + if !token.permanent { + tokensEmpty = false + } + } + + let clearIsHidden = (textIsEmpty && tokensEmpty) && self.prefixString == nil transition.updateAlpha(node: self.clearButton.imageNode, alpha: clearIsHidden ? 0.0 : 1.0) transition.updateTransformScale(node: self.clearButton, scale: clearIsHidden ? 0.2 : 1.0) self.clearButton.isUserInteractionEnabled = !clearIsHidden diff --git a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift index a4902bd51a..df4324f164 100644 --- a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift +++ b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift @@ -219,7 +219,7 @@ private final class TextSizeSelectionControllerNode: ASDisplayNode, UIScrollView var items: [ChatListItem] = [] let interaction = ChatListNodeInteraction(context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in - }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, hidePsa: { _ in + }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() }, present: { _ in }) diff --git a/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift b/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift index ebeed3932e..8012376327 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeAccentColorControllerNode.swift @@ -839,7 +839,7 @@ final class ThemeAccentColorControllerNode: ASDisplayNode, UIScrollViewDelegate var items: [ChatListItem] = [] let interaction = ChatListNodeInteraction(context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in - }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, hidePsa: { _ in + }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() }, present: { _ in diff --git a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift index 8eea1a9736..547ac2726b 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift @@ -363,7 +363,7 @@ final class ThemePreviewControllerNode: ASDisplayNode, UIScrollViewDelegate { var items: [ChatListItem] = [] let interaction = ChatListNodeInteraction(context: self.context, animationCache: self.animationCache, animationRenderer: self.animationRenderer, activateSearch: {}, peerSelected: { _, _, _, _ in }, disabledPeerSelected: { _, _ in }, togglePeerSelected: { _, _ in }, togglePeersSelection: { _, _ in }, additionalCategorySelected: { _ in - }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, hidePsa: { _ in + }, messageSelected: { _, _, _, _ in}, groupSelected: { _ in }, addContact: { _ in }, setPeerIdWithRevealedOptions: { _, _ in }, setItemPinned: { _, _ in }, setPeerMuted: { _, _ in }, setPeerThreadMuted: { _, _, _ in }, deletePeer: { _, _ in }, deletePeerThread: { _, _ in }, setPeerThreadStopped: { _, _, _ in }, setPeerThreadPinned: { _, _, _ in }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in}, toggleArchivedFolderHiddenByDefault: {}, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { _, _, gesture, _ in gesture?.cancel() }, present: { _ in diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index a968a5e7aa..d1b9fa5a7f 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -397,7 +397,13 @@ final class ShareControllerNode: ViewControllerTracingNode, UIScrollViewDelegate strongSelf.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate) } - if let peersContentNode = strongSelf.peersContentNode { + if let searchContentNode = strongSelf.contentNode as? ShareSearchContainerNode { + searchContentNode.setContentOffsetUpdated(nil) + let scrollDelta = topicsContentNode.contentGridNode.scrollView.contentOffset.y - searchContentNode.contentGridNode.scrollView.contentOffset.y + if let sourceFrame = searchContentNode.animateOut(peerId: peer.peerId, scrollDelta: scrollDelta) { + topicsContentNode.animateIn(sourceFrame: sourceFrame, scrollDelta: scrollDelta) + } + } else if let peersContentNode = strongSelf.peersContentNode { peersContentNode.setContentOffsetUpdated(nil) let scrollDelta = topicsContentNode.contentGridNode.scrollView.contentOffset.y - peersContentNode.contentGridNode.scrollView.contentOffset.y if let sourceFrame = peersContentNode.animateOut(peerId: peer.peerId, scrollDelta: scrollDelta) { @@ -413,8 +419,14 @@ final class ShareControllerNode: ViewControllerTracingNode, UIScrollViewDelegate } func closePeerTopics(_ peerId: EnginePeer.Id) { - if let topicsContentNode = self.topicsContentNode, let peersContentNode = self.peersContentNode { - topicsContentNode.setContentOffsetUpdated(nil) + guard let topicsContentNode = self.topicsContentNode else { + return + } + topicsContentNode.setContentOffsetUpdated(nil) + + if let searchContentNode = self.contentNode as? ShareSearchContainerNode { + topicsContentNode.supernode?.insertSubnode(topicsContentNode, belowSubnode: searchContentNode) + } else if let peersContentNode = self.peersContentNode { topicsContentNode.supernode?.insertSubnode(topicsContentNode, belowSubnode: peersContentNode) } @@ -422,14 +434,27 @@ final class ShareControllerNode: ViewControllerTracingNode, UIScrollViewDelegate self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .animated(duration: 0.4, curve: .spring)) } - if let peersContentNode = self.peersContentNode { + if let searchContentNode = self.contentNode as? ShareSearchContainerNode { + searchContentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in + self?.contentNodeOffsetUpdated(contentOffset, transition: transition) + }) + self.contentNodeOffsetUpdated(searchContentNode.contentGridNode.scrollView.contentOffset.y, transition: .animated(duration: 0.4, curve: .spring)) + + let scrollDelta = topicsContentNode.contentGridNode.scrollView.contentOffset.y - searchContentNode.contentGridNode.scrollView.contentOffset.y + if let targetFrame = searchContentNode.animateIn(peerId: peerId, scrollDelta: scrollDelta) { + topicsContentNode.animateOut(targetFrame: targetFrame, scrollDelta: scrollDelta, completion: { [weak self] in + if let topicsContentNode = self?.topicsContentNode { + topicsContentNode.removeFromSupernode() + self?.topicsContentNode = nil + } + }) + } + } else if let peersContentNode = self.peersContentNode { peersContentNode.setContentOffsetUpdated({ [weak self] contentOffset, transition in self?.contentNodeOffsetUpdated(contentOffset, transition: transition) }) self.contentNodeOffsetUpdated(peersContentNode.contentGridNode.scrollView.contentOffset.y, transition: .animated(duration: 0.4, curve: .spring)) - } - - if let peersContentNode = self.peersContentNode, let topicsContentNode = self.topicsContentNode { + let scrollDelta = topicsContentNode.contentGridNode.scrollView.contentOffset.y - peersContentNode.contentGridNode.scrollView.contentOffset.y if let targetFrame = peersContentNode.animateIn(peerId: peerId, scrollDelta: scrollDelta) { topicsContentNode.animateOut(targetFrame: targetFrame, scrollDelta: scrollDelta, completion: { [weak self] in diff --git a/submodules/ShareController/Sources/SharePeersContainerNode.swift b/submodules/ShareController/Sources/SharePeersContainerNode.swift index b51b6d8a5d..9f1f0b61de 100644 --- a/submodules/ShareController/Sources/SharePeersContainerNode.swift +++ b/submodules/ShareController/Sources/SharePeersContainerNode.swift @@ -17,7 +17,7 @@ import ContextUI private let subtitleFont = Font.regular(12.0) -private extension CGPoint { +extension CGPoint { func angle(to other: CGPoint) -> CGFloat { let originX = other.x - self.x let originY = other.y - self.y @@ -377,22 +377,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { return nil } } - - func generateMaskImage() -> UIImage? { - return generateImage(CGSize(width: 100.0, height: 100.0), contextGenerator: { size, context in - context.clear(CGRect(origin: .zero, size: size)) - - let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 16.0, dy: 16.0), cornerRadius: 16.0) - context.setFillColor(UIColor.white.cgColor) - context.setShadow(offset: .zero, blur: 40.0, color: UIColor.white.cgColor) - - for _ in 0 ..< 10 { - context.addPath(path.cgPath) - context.fillPath() - } - })?.stretchableImage(withLeftCapWidth: 49, topCapHeight: 49) - } - + func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) @@ -420,7 +405,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { let clippedNode = ASDisplayNode() clippedNode.clipsToBounds = true clippedNode.cornerRadius = 16.0 - clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.contentTitleNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) + clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.headerNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset + 15.0)) self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) clippedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) @@ -429,7 +414,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { maskView.frame = clippedNode.bounds let maskImageView = UIImageView() - maskImageView.image = generateMaskImage() + maskImageView.image = generatePeersMaskImage() maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) maskView.addSubview(maskImageView) clippedNode.view.mask = maskView @@ -493,7 +478,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { let clippedNode = ASDisplayNode() clippedNode.clipsToBounds = true clippedNode.cornerRadius = 16.0 - clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.contentTitleNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) + clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.headerNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset + 15.0)) self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) clippedNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) @@ -502,7 +487,7 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { maskView.frame = clippedNode.bounds let maskImageView = UIImageView() - maskImageView.image = generateMaskImage() + maskImageView.image = generatePeersMaskImage() maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) maskView.addSubview(maskImageView) clippedNode.view.mask = maskView @@ -727,3 +712,18 @@ final class SharePeersContainerNode: ASDisplayNode, ShareContentContainerNode { } } } + +func generatePeersMaskImage() -> UIImage? { + return generateImage(CGSize(width: 100.0, height: 100.0), contextGenerator: { size, context in + context.clear(CGRect(origin: .zero, size: size)) + + let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 16.0, dy: 16.0), cornerRadius: 16.0) + context.setFillColor(UIColor.white.cgColor) + context.setShadow(offset: .zero, blur: 40.0, color: UIColor.white.cgColor) + + for _ in 0 ..< 10 { + context.addPath(path.cgPath) + context.fillPath() + } + })?.stretchableImage(withLeftCapWidth: 49, topCapHeight: 49) +} diff --git a/submodules/ShareController/Sources/ShareSearchContainerNode.swift b/submodules/ShareController/Sources/ShareSearchContainerNode.swift index a2a26149f2..d940693d57 100644 --- a/submodules/ShareController/Sources/ShareSearchContainerNode.swift +++ b/submodules/ShareController/Sources/ShareSearchContainerNode.swift @@ -173,7 +173,7 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { private var enqueuedTransitions: [(ShareSearchGridTransaction, Bool)] = [] private var enqueuedRecentTransitions: [(ShareSearchGridTransaction, Bool)] = [] - private let contentGridNode: GridNode + let contentGridNode: GridNode private let recentGridNode: GridNode private let contentSeparatorNode: ASDisplayNode @@ -632,4 +632,146 @@ final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { self.recentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) } } + + func frameForPeerId(_ peerId: EnginePeer.Id) -> CGRect? { + var node: ASDisplayNode? + if !self.recentGridNode.isHidden { + self.recentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + node = itemNode + } + } + } else { + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + node = itemNode + } + } + } + if let node = node { + return node.frame.offsetBy(dx: 0.0, dy: -10.0) + } else { + return nil + } + } + + func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { + self.searchNode.alpha = 1.0 + self.searchNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.searchNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + self.cancelButtonNode.alpha = 1.0 + self.cancelButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.cancelButtonNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + self.contentGridNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + if let targetFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout { + let clippedNode = ASDisplayNode() + clippedNode.clipsToBounds = true + clippedNode.cornerRadius = 16.0 + clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.searchNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) + self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) + + clippedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + let maskView = UIView() + maskView.frame = clippedNode.bounds + + let maskImageView = UIImageView() + maskImageView.image = generatePeersMaskImage() + maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) + maskView.addSubview(maskImageView) + clippedNode.view.mask = maskView + + + self.contentGridNode.alpha = 1.0 + self.contentGridNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, removeOnCompletion: false) + itemNode.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak clippedNode] _ in + clippedNode?.view.removeFromSuperview() + }) + } else if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view) + + clippedNode.view.addSubview(snapshotView) + + itemNode.alpha = 0.0 + let angle = targetFrame.center.angle(to: itemNode.position) + let distance = targetFrame.center.distance(to: itemNode.position) + let newDistance = distance * 2.8 + let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle) + snapshotView.layer.animatePosition(from: newPosition, to: snapshotView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + snapshotView.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak itemNode] _ in + itemNode?.alpha = 1.0 + }) + snapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, removeOnCompletion: false) + } + } + + return targetFrame + } else { + return nil + } + } + + func animateOut(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { + self.searchNode.alpha = 0.0 + self.searchNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.searchNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + self.cancelButtonNode.alpha = 0.0 + self.cancelButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) + self.cancelButtonNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + self.contentGridNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + if let sourceFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout { + let clippedNode = ASDisplayNode() + clippedNode.clipsToBounds = true + clippedNode.cornerRadius = 16.0 + clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.searchNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) + self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) + + clippedNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) + + let maskView = UIView() + maskView.frame = clippedNode.bounds + + let maskImageView = UIImageView() + maskImageView.image = generatePeersMaskImage() + maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) + maskView.addSubview(maskImageView) + clippedNode.view.mask = maskView + + self.contentGridNode.forEachItemNode { itemNode in + if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view) + clippedNode.view.addSubview(snapshotView) + + if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { + + } else { + let angle = sourceFrame.center.angle(to: itemNode.position) + let distance = sourceFrame.center.distance(to: itemNode.position) + let newDistance = distance * 2.8 + let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle) + snapshotView.layer.animatePosition(from: snapshotView.center, to: newPosition, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) + } + snapshotView.layer.animateScale(from: 1.0, to: 1.35, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + } + } + + clippedNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak clippedNode] _ in + clippedNode?.view.removeFromSuperview() + }) + + self.contentGridNode.alpha = 0.0 + + return sourceFrame + } else { + return nil + } + } } diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index cb0d5cb3dd..ee9a545b31 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -3501,7 +3501,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } }, displaySwipeToReplyHint: { [weak self] in if let strongSelf = self, let validLayout = strongSelf.validLayout, min(validLayout.size.width, validLayout.size.height) > 320.0 { - strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .swipeToReply(title: strongSelf.presentationData.strings.Conversation_SwipeToReplyHintTitle, text: strongSelf.presentationData.strings.Conversation_SwipeToReplyHintText), elevatedLayout: false, action: { _ in return false }), in: .current) + strongSelf.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .swipeToReply(title: strongSelf.presentationData.strings.Conversation_SwipeToReplyHintTitle, text: strongSelf.presentationData.strings.Conversation_SwipeToReplyHintText), elevatedLayout: false, position: .top, action: { _ in return false }), in: .current) } }, dismissReplyMarkupMessage: { [weak self] message in guard let strongSelf = self, strongSelf.presentationInterfaceState.keyboardButtonsMessage?.id == message.id else { @@ -4028,6 +4028,8 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G self?.chatDisplayNode.cancelInteractiveKeyboardGestures() }, dismissTextInput: { [weak self] in self?.chatDisplayNode.dismissTextInput() + }, scrollToMessageId: { [weak self] index in + self?.chatDisplayNode.historyNode.scrollToMessage(index: index) }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: self.stickerSettings, presentationContext: ChatPresentationContext(context: context, backgroundNode: self.chatBackgroundNode)) self.controllerInteraction = controllerInteraction @@ -9916,7 +9918,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } self.tempVoicePlaylistItemChanged = { [weak self] previousItem, currentItem in - guard let strongSelf = self, case .peer = strongSelf.chatLocation else { + guard let strongSelf = self else { return } @@ -15078,7 +15080,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) } } - controller.peerSelected = { [weak self, weak controller] peer, _ in + controller.peerSelected = { [weak self, weak controller] peer, threadId in guard let strongSelf = self, let strongController = controller else { return } @@ -15165,34 +15167,46 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G } } - let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: nil, { currentState in + let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: threadId, { currentState in return currentState.withUpdatedForwardMessageIds(messages.map { $0.id }).withUpdatedForwardOptionsState(ChatInterfaceForwardOptionsState(hideNames: !hasNotOwnMessages, hideCaptions: false, unhideNamesOnCaptionChange: false)) }) |> deliverOnMainQueue).start(completed: { if let strongSelf = self { - strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) - - let navigationController: NavigationController? - if let parentController = strongSelf.parentController { - navigationController = (parentController.navigationController as? NavigationController) - } else { - navigationController = strongSelf.effectiveNavigationController - } - - if let navigationController = navigationController { - let chatController = ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId)) - var viewControllers = navigationController.viewControllers - viewControllers.insert(chatController, at: viewControllers.count - 1) - navigationController.setViewControllers(viewControllers, animated: false) + let proceed: (ChatController) -> Void = { chatController in + strongSelf.updateChatPresentationInterfaceState(animated: false, interactive: true, { $0.updatedInterfaceState({ $0.withoutSelectionState() }) }) - strongSelf.controllerNavigationDisposable.set((chatController.ready.get() - |> SwiftSignalKit.filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(next: { _ in - if let strongController = controller { - strongController.dismiss() + let navigationController: NavigationController? + if let parentController = strongSelf.parentController { + navigationController = (parentController.navigationController as? NavigationController) + } else { + navigationController = strongSelf.effectiveNavigationController + } + + if let navigationController = navigationController { + var viewControllers = navigationController.viewControllers + if threadId != nil { + viewControllers.insert(chatController, at: viewControllers.count - 2) + } else { + viewControllers.insert(chatController, at: viewControllers.count - 1) } - })) + navigationController.setViewControllers(viewControllers, animated: false) + + strongSelf.controllerNavigationDisposable.set((chatController.ready.get() + |> SwiftSignalKit.filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak navigationController] _ in + viewControllers.removeAll(where: { $0 is PeerSelectionController }) + navigationController?.setViewControllers(viewControllers, animated: true) + })) + } + } + if let threadId = threadId { + let _ = (strongSelf.context.sharedContext.chatControllerForForumThread(context: strongSelf.context, peerId: peerId, threadId: threadId) + |> deliverOnMainQueue).start(next: { chatController in + proceed(chatController) + }) + } else { + proceed(ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId))) } } }) @@ -15295,7 +15309,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G case let .chat(textInputState, _, _): if let textInputState = textInputState { let controller = self.context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: self.context, updatedPresentationData: self.updatedPresentationData)) - controller.peerSelected = { [weak self, weak controller] peer, _ in + controller.peerSelected = { [weak self, weak controller] peer, threadId in let peerId = peer.id if let strongSelf = self, let strongController = controller { @@ -15309,7 +15323,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G }) strongController.dismiss() } else { - let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: nil, { currentState in + let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: threadId, { currentState in return currentState.withUpdatedComposeInputState(textInputState) }) |> deliverOnMainQueue).start(completed: { diff --git a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift index 1c739ca79b..851b3f5f3b 100644 --- a/submodules/TelegramUI/Sources/ChatControllerInteraction.swift +++ b/submodules/TelegramUI/Sources/ChatControllerInteraction.swift @@ -145,6 +145,7 @@ public final class ChatControllerInteraction { let requestMessageUpdate: (MessageId) -> Void let cancelInteractiveKeyboardGestures: () -> Void let dismissTextInput: () -> Void + let scrollToMessageId: (MessageIndex) -> Void var canPlayMedia: Bool = false var hiddenMedia: [MessageId: [Media]] = [:] @@ -250,6 +251,7 @@ public final class ChatControllerInteraction { requestMessageUpdate: @escaping (MessageId) -> Void, cancelInteractiveKeyboardGestures: @escaping () -> Void, dismissTextInput: @escaping () -> Void, + scrollToMessageId: @escaping (MessageIndex) -> Void, automaticMediaDownloadSettings: MediaAutoDownloadSettings, pollActionState: ChatInterfacePollActionState, stickerSettings: ChatInterfaceStickerSettings, @@ -339,6 +341,7 @@ public final class ChatControllerInteraction { self.requestMessageUpdate = requestMessageUpdate self.cancelInteractiveKeyboardGestures = cancelInteractiveKeyboardGestures self.dismissTextInput = dismissTextInput + self.scrollToMessageId = scrollToMessageId self.automaticMediaDownloadSettings = automaticMediaDownloadSettings diff --git a/submodules/TelegramUI/Sources/ChatControllerNode.swift b/submodules/TelegramUI/Sources/ChatControllerNode.swift index 95340c5df2..bf8d93bd69 100644 --- a/submodules/TelegramUI/Sources/ChatControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatControllerNode.swift @@ -221,16 +221,28 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { private var derivedLayoutState: ChatControllerNodeDerivedLayoutState? private var isLoadingValue: Bool = false - private func updateIsLoading(isLoading: Bool, animated: Bool) { + private var isLoadingEarlier: Bool = false + private func updateIsLoading(isLoading: Bool, earlier: Bool, animated: Bool) { let useLoadingPlaceholder = self.chatLocation.peerId?.namespace != Namespaces.Peer.CloudUser - if isLoading != self.isLoadingValue { + let updated = isLoading != self.isLoadingValue || (isLoading && earlier && !self.isLoadingEarlier) + + if updated { + let updatedIsLoading = self.isLoadingValue != isLoading self.isLoadingValue = isLoading + + let updatedIsEarlier = self.isLoadingEarlier != earlier && !updatedIsLoading + self.isLoadingEarlier = earlier + if isLoading { if useLoadingPlaceholder { let loadingPlaceholderNode: ChatLoadingPlaceholderNode if let current = self.loadingPlaceholderNode { loadingPlaceholderNode = current + + if updatedIsEarlier { + loadingPlaceholderNode.setup(self.historyNode, updating: true) + } } else { loadingPlaceholderNode = ChatLoadingPlaceholderNode(theme: self.chatPresentationInterfaceState.theme, chatWallpaper: self.chatPresentationInterfaceState.chatWallpaper, bubbleCorners: self.chatPresentationInterfaceState.bubbleCorners, backgroundNode: self.backgroundNode) loadingPlaceholderNode.updatePresentationInterfaceState(self.chatPresentationInterfaceState) @@ -238,7 +250,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { self.loadingPlaceholderNode = loadingPlaceholderNode - loadingPlaceholderNode.setup(self.historyNode) + loadingPlaceholderNode.setup(self.historyNode, updating: false) if let (layout, navigationHeight) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationHeight, transition: .immediate, listViewTransaction: { _, _, _, _ in @@ -509,10 +521,10 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { self.historyNode.setLoadStateUpdated { [weak self] loadState, animated in if let strongSelf = self { let wasLoading = strongSelf.isLoadingValue - if case .loading = loadState { - strongSelf.updateIsLoading(isLoading: true, animated: animated) + if case let .loading(earlier) = loadState { + strongSelf.updateIsLoading(isLoading: true, earlier: earlier, animated: animated) } else { - strongSelf.updateIsLoading(isLoading: false, animated: animated) + strongSelf.updateIsLoading(isLoading: false, earlier: false, animated: animated) } var emptyType: ChatHistoryNodeLoadState.EmptyType? @@ -3190,8 +3202,13 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { var attributes: [MessageAttribute] = [] attributes.append(ForwardOptionsMessageAttribute(hideNames: self.chatPresentationInterfaceState.interfaceState.forwardOptionsState?.hideNames == true, hideCaptions: self.chatPresentationInterfaceState.interfaceState.forwardOptionsState?.hideCaptions == true)) + var replyThreadId: Int64? + if case let .replyThread(replyThreadMessage) = self.chatPresentationInterfaceState.chatLocation { + replyThreadId = Int64(replyThreadMessage.messageId.id) + } + for id in forwardMessageIds.sorted() { - messages.append(.forward(source: id, threadId: nil, grouping: .auto, attributes: attributes, correlationId: nil)) + messages.append(.forward(source: id, threadId: replyThreadId, grouping: .auto, attributes: attributes, correlationId: nil)) } } diff --git a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift index ab786bd484..198fe473b6 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryListNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryListNode.swift @@ -555,6 +555,9 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { } } + private var appliedScrollToMessageId: MessageIndex? = nil + private let scrollToMessageIdPromise = Promise(nil) + private let currentlyPlayingMessageIdPromise = Promise<(MessageIndex, Bool)?>(nil) private var appliedPlayingMessageId: MessageIndex? = nil @@ -1043,11 +1046,12 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { customChannelDiscussionReadState, customThreadOutgoingReadState, self.currentlyPlayingMessageIdPromise.get(), + self.scrollToMessageIdPromise.get(), adMessages, availableReactions, defaultReaction, accountPeer - ).start(next: { [weak self] update, chatPresentationData, selectedMessages, updatingMedia, networkType, historyAppearsCleared, pendingUnpinnedAllMessages, pendingRemovedMessages, animatedEmojiStickers, additionalAnimatedEmojiStickers, customChannelDiscussionReadState, customThreadOutgoingReadState, currentlyPlayingMessageIdAndType, adMessages, availableReactions, defaultReaction, accountPeer in + ).start(next: { [weak self] update, chatPresentationData, selectedMessages, updatingMedia, networkType, historyAppearsCleared, pendingUnpinnedAllMessages, pendingRemovedMessages, animatedEmojiStickers, additionalAnimatedEmojiStickers, customChannelDiscussionReadState, customThreadOutgoingReadState, currentlyPlayingMessageIdAndType, scrollToMessageId, adMessages, availableReactions, defaultReaction, accountPeer in let currentlyPlayingMessageId = currentlyPlayingMessageIdAndType?.0 func applyHole() { @@ -1139,7 +1143,7 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { strongSelf._cachedPeerDataAndMessages.set(.single((cachedData, cachedDataMessages))) - let loadState: ChatHistoryNodeLoadState = .loading + let loadState: ChatHistoryNodeLoadState = .loading(false) if strongSelf.loadState != loadState { strongSelf.loadState = loadState strongSelf.loadStateUpdated?(loadState, false) @@ -1258,15 +1262,20 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { var scrollAnimationCurve: ListViewAnimationCurve? = nil if let strongSelf = self, case .default = source { - let wasPlaying = strongSelf.appliedPlayingMessageId != nil - - if strongSelf.appliedPlayingMessageId != currentlyPlayingMessageId, let (currentlyPlayingMessageId, currentlyPlayingVideo) = currentlyPlayingMessageIdAndType { - if isFirstTime { - } else if case let .peer(peerId) = chatLocation, currentlyPlayingMessageId.id.peerId != peerId { - } else { - if wasPlaying || currentlyPlayingVideo { - updatedScrollPosition = .index(index: .message(currentlyPlayingMessageId), position: .center(.bottom), directionHint: .Up, animated: true, highlight: true, displayLink: true) - scrollAnimationCurve = .Spring(duration: 0.4) + if strongSelf.appliedScrollToMessageId == nil, let scrollToMessageId = scrollToMessageId { + updatedScrollPosition = .index(index: .message(scrollToMessageId), position: .center(.top), directionHint: .Up, animated: true, highlight: false, displayLink: true) + scrollAnimationCurve = .Spring(duration: 0.4) + } else { + let wasPlaying = strongSelf.appliedPlayingMessageId != nil + + if strongSelf.appliedPlayingMessageId != currentlyPlayingMessageId, let (currentlyPlayingMessageId, currentlyPlayingVideo) = currentlyPlayingMessageIdAndType { + if isFirstTime { + } else if case let .peer(peerId) = chatLocation, currentlyPlayingMessageId.id.peerId != peerId { + } else { + if wasPlaying || currentlyPlayingVideo { + updatedScrollPosition = .index(index: .message(currentlyPlayingMessageId), position: .center(.bottom), directionHint: .Up, animated: true, highlight: true, displayLink: true) + scrollAnimationCurve = .Spring(duration: 0.4) + } } } } @@ -1337,6 +1346,9 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { if strongSelf.appliedPlayingMessageId != currentlyPlayingMessageId { strongSelf.appliedPlayingMessageId = currentlyPlayingMessageId } + if strongSelf.appliedScrollToMessageId != scrollToMessageId { + strongSelf.appliedScrollToMessageId = scrollToMessageId + } strongSelf.enqueueHistoryViewTransition(mappedTransition) } } @@ -2560,13 +2572,13 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { } } else { if historyView.originalView.isLoadingEarlier && strongSelf.chatLocation.peerId?.namespace != Namespaces.Peer.CloudUser { - loadState = .loading + loadState = .loading(true) } else { loadState = .messages } } } else { - loadState = .loading + loadState = .loading(false) } var animateIn = false @@ -3343,6 +3355,11 @@ public final class ChatHistoryListNode: ListView, ChatHistoryNode { self.currentlyPlayingMessageIdPromise.set(.single(nil)) } } + + func scrollToMessage(index: MessageIndex) { + self.appliedScrollToMessageId = nil + self.scrollToMessageIdPromise.set(.single(index)) + } private var currentSendAnimationCorrelationIds: Set? func setCurrentSendAnimationCorrelationIds(_ value: Set?) { diff --git a/submodules/TelegramUI/Sources/ChatHistoryNode.swift b/submodules/TelegramUI/Sources/ChatHistoryNode.swift index b9a76f29b2..53e35abf2d 100644 --- a/submodules/TelegramUI/Sources/ChatHistoryNode.swift +++ b/submodules/TelegramUI/Sources/ChatHistoryNode.swift @@ -14,7 +14,7 @@ public enum ChatHistoryNodeLoadState: Equatable { case topic } - case loading + case loading(Bool) case empty(EmptyType) case messages } diff --git a/submodules/TelegramUI/Sources/ChatLoadingNode.swift b/submodules/TelegramUI/Sources/ChatLoadingNode.swift index 3f76c67250..a080cab335 100644 --- a/submodules/TelegramUI/Sources/ChatLoadingNode.swift +++ b/submodules/TelegramUI/Sources/ChatLoadingNode.swift @@ -98,21 +98,7 @@ final class ChatLoadingPlaceholderMessageContainer { videoItemNode.animateFromLoadingPlaceholder(messageContainer: self, delay: delay, transition: transition) } } - - func animateBackgroundFrame(to frame: CGRect, transition: ContainedViewLayoutTransition) { -// let targetFrame = CGRect(origin: CGPoint(x: self.bubbleNode.frame.minX, y: frame.minY), size: frame.size) -// -// transition.updateFrame(node: self.bubbleNode, frame: targetFrame) -// transition.updateFrame(node: self.bubbleBorderNode, frame: targetFrame) -// -// if let avatarNode = self.avatarNode, let avatarBorderNode = self.avatarBorderNode { -// let avatarFrame = CGRect(origin: CGPoint(x: 3.0, y: frame.maxY + 1.0 - avatarSize.height), size: avatarSize) -// -// transition.updateFrame(node: avatarNode, frame: avatarFrame) -// transition.updateFrame(node: avatarBorderNode, frame: avatarFrame) -// } - } - + func update(size: CGSize, hasAvatar: Bool, rect: CGRect, transition: ContainedViewLayoutTransition) { var avatarOffset: CGFloat = 0.0 @@ -151,7 +137,6 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { private let maskNode: ASDisplayNode private let borderMaskNode: ASDisplayNode - private let scrollingContainer: ASDisplayNode private let containerNode: ASDisplayNode private var backgroundContent: WallpaperBubbleBackgroundNode? private let backgroundColorNode: ASDisplayNode @@ -169,7 +154,6 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { init(theme: PresentationTheme, chatWallpaper: TelegramWallpaper, bubbleCorners: PresentationChatBubbleCorners, backgroundNode: WallpaperBackgroundNode) { self.backgroundNode = backgroundNode - self.scrollingContainer = ASDisplayNode() self.maskNode = ASDisplayNode() self.borderMaskNode = ASDisplayNode() @@ -198,13 +182,11 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { super.init() - self.addSubnode(self.scrollingContainer) - - self.scrollingContainer.addSubnode(self.containerNode) + self.addSubnode(self.containerNode) self.containerNode.addSubnode(self.backgroundColorNode) self.containerNode.addSubnode(self.effectNode) - self.scrollingContainer.addSubnode(self.borderNode) + self.addSubnode(self.borderNode) self.borderNode.addSubnode(self.borderEffectNode) } @@ -218,25 +200,73 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { } private var bottomInset: (Int, CGFloat)? - func setup(_ historyNode: ChatHistoryNode) { + func setup(_ historyNode: ChatHistoryNode, updating: Bool = false) { guard let listNode = historyNode as? ListView else { return } + var listItemNodes: [ASDisplayNode] = [] var count = 0 var inset: CGFloat = 0.0 listNode.forEachVisibleItemNode { itemNode in inset += itemNode.frame.height count += 1 + + listItemNodes.append(itemNode) + } + + + if updating { + let heightNorm = listNode.bounds.height - listNode.insets.top + listNode.forEachItemHeaderNode { itemNode in + var animateScale = true + if itemNode is ChatMessageAvatarHeaderNode { + animateScale = false + } + + let delayFactor = itemNode.frame.minY / heightNorm + let delay = Double(delayFactor * 0.2) + + itemNode.allowsGroupOpacity = true + itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, delay: delay, completion: { [weak itemNode] _ in + itemNode?.allowsGroupOpacity = false + }) + if animateScale { + itemNode.layer.animateScale(from: 0.94, to: 1.0, duration: 0.4, delay: delay, timingFunction: kCAMediaTimingFunctionSpring) + } + } } if count > 0 { self.bottomInset = (count, inset) } - self.scrollingContainer.bounds = self.scrollingContainer.bounds.offsetBy(dx: 0.0, dy: inset) + if updating { + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .spring) + transition.animateOffsetAdditive(node: self.maskNode, offset: -inset) + transition.animateOffsetAdditive(node: self.borderMaskNode, offset: -inset) + + for listItemNode in listItemNodes { + var incoming = false + if let itemNode = listItemNode as? ChatMessageItemView, let item = itemNode.item, item.message.effectivelyIncoming(item.context.account.peerId) { + incoming = true + } + + transition.animatePositionAdditive(node: listItemNode, offset: CGPoint(x: incoming ? 30.0 : -30.0, y: -30.0)) + transition.animateTransformScale(node: listItemNode, from: CGPoint(x: 0.85, y: 0.85)) + + listItemNode.allowsGroupOpacity = true + listItemNode.alpha = 1.0 + listItemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { _ in + listItemNode.allowsGroupOpacity = false + }) + } + } + + self.maskNode.bounds = self.maskNode.bounds.offsetBy(dx: 0.0, dy: inset) + self.borderMaskNode.bounds = self.borderMaskNode.bounds.offsetBy(dx: 0.0, dy: inset) } - + func animateOut(_ historyNode: ChatHistoryNode, completion: @escaping () -> Void = {}) { guard let listNode = historyNode as? ListView, let (size, _) = self.validLayout else { return @@ -325,8 +355,10 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { } func addContentOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { - self.scrollingContainer.bounds = self.scrollingContainer.bounds.offsetBy(dx: 0.0, dy: -offset) - transition.animateOffsetAdditive(node: self.scrollingContainer, offset: offset) + self.maskNode.bounds = self.maskNode.bounds.offsetBy(dx: 0.0, dy: -offset) + self.borderMaskNode.bounds = self.borderMaskNode.bounds.offsetBy(dx: 0.0, dy: -offset) + transition.animateOffsetAdditive(node: self.maskNode, offset: offset) + transition.animateOffsetAdditive(node: self.borderMaskNode, offset: offset) if let (rect, containerSize) = self.absolutePosition { self.update(rect: rect, within: containerSize) } @@ -337,7 +369,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { if let backgroundContent = self.backgroundContent { var backgroundFrame = backgroundContent.frame backgroundFrame.origin.x += rect.minX - backgroundFrame.origin.y += rect.minY - self.scrollingContainer.bounds.minY + backgroundFrame.origin.y += rect.minY backgroundContent.update(rect: backgroundFrame, within: containerSize, transition: transition) } } @@ -365,9 +397,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { self.validLayout = (size, insets) let bounds = CGRect(origin: .zero, size: size) - - transition.updateFrame(node: self.scrollingContainer, frame: bounds) - + transition.updateFrame(node: self.maskNode, frame: bounds) transition.updateFrame(node: self.borderMaskNode, frame: bounds) transition.updateFrame(node: self.containerNode, frame: bounds) @@ -401,13 +431,13 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { var offset: CGFloat = 5.0 var index = 0 - if let (insetCount, _) = self.bottomInset { - index += insetCount - } +// if let (insetCount, _) = self.bottomInset { +// index += insetCount +// } for messageContainer in self.messageContainers { let messageSize = dimensions[index % 11] - messageContainer.update(size: size, hasAvatar: self.isGroup, rect: CGRect(origin: CGPoint(x: 0.0, y: size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) + messageContainer.update(size: bounds.size, hasAvatar: self.isGroup, rect: CGRect(origin: CGPoint(x: 0.0, y: bounds.size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) offset += messageSize.height index += 1 } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift index a13b92843f..52711efaf8 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleContentNode.swift @@ -26,6 +26,23 @@ struct ChatMessageBubbleContentProperties { let hidesBackground: ChatMessageBubbleContentBackgroundHiding let forceFullCorners: Bool let forceAlignment: ChatMessageBubbleContentAlignment + let shareButtonOffset: CGPoint? + + init( + hidesSimpleAuthorHeader: Bool, + headerSpacing: CGFloat, + hidesBackground: ChatMessageBubbleContentBackgroundHiding, + forceFullCorners: Bool, + forceAlignment: ChatMessageBubbleContentAlignment, + shareButtonOffset: CGPoint? = nil + ) { + self.hidesSimpleAuthorHeader = hidesSimpleAuthorHeader + self.headerSpacing = headerSpacing + self.hidesBackground = hidesBackground + self.forceFullCorners = forceFullCorners + self.forceAlignment = forceAlignment + self.shareButtonOffset = shareButtonOffset + } } enum ChatMessageBubbleNoneMergeStatus { @@ -136,6 +153,7 @@ class ChatMessageBubbleContentNode: ASDisplayNode { } weak var bubbleBackgroundNode: ChatMessageBackground? + weak var bubbleBackdropNode: ChatMessageBubbleBackdrop? var visibility: ListViewItemNodeVisibility = .none @@ -143,6 +161,10 @@ class ChatMessageBubbleContentNode: ASDisplayNode { var updateIsTextSelectionActive: ((Bool) -> Void)? + var disablesClipping: Bool { + return false + } + required override init() { super.init() } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 91fd93c81e..a5783465c0 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -84,7 +84,7 @@ private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> ([ let isVideo = file.isVideo || (file.isAnimated && file.dimensions != nil) if isVideo { if file.isInstantVideo { -// result.append((message, ChatMessageInstantVideoBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) + result.append((message, ChatMessageInstantVideoBubbleContentNode.self, itemAttributes, BubbleItemAttributes(isAttachment: false, neighborType: .freeform, neighborSpacing: .default))) } else { if let forwardInfo = message.forwardInfo, forwardInfo.flags.contains(.isImported), message.text.isEmpty { messageWithCaptionToAdd = (message, itemAttributes) @@ -1213,10 +1213,15 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } + var isInstantVideo = false if let forwardInfo = item.content.firstMessage.forwardInfo, forwardInfo.source == nil, forwardInfo.author?.id.namespace == Namespaces.Peer.CloudUser { for media in item.content.firstMessage.media { - if let file = media as? TelegramMediaFile, file.isMusic { - ignoreForward = true + if let file = media as? TelegramMediaFile { + if file.isMusic { + ignoreForward = true + } else if file.isInstantVideo { + isInstantVideo = true + } break } } @@ -1329,12 +1334,22 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode tmpWidth -= deliveryFailedInset - let maximumContentWidth = floor(tmpWidth - layoutConstants.bubble.edgeInset * 3.0 - layoutConstants.bubble.contentInsets.left - layoutConstants.bubble.contentInsets.right - avatarInset) + let (contentNodeMessagesAndClasses, needSeparateContainers, needReactions) = contentNodeMessagesAndClassesForItem(item) + + var maximumContentWidth = floor(tmpWidth - layoutConstants.bubble.edgeInset * 3.0 - layoutConstants.bubble.contentInsets.left - layoutConstants.bubble.contentInsets.right - avatarInset) + + var hasInstantVideo = false + for contentNodeItemValue in contentNodeMessagesAndClasses { + let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) + if contentNodeItem.type == ChatMessageInstantVideoBubbleContentNode.self, !contentNodeItem.bubbleAttributes.isAttachment { + maximumContentWidth = baseWidth - 20.0 + hasInstantVideo = true + break + } + } var contentPropertiesAndPrepareLayouts: [(Message, Bool, ChatMessageEntryAttributes, BubbleItemAttributes, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))))] = [] var addedContentNodes: [(Message, Bool, ChatMessageBubbleContentNode)]? - - let (contentNodeMessagesAndClasses, needSeparateContainers, needReactions) = contentNodeMessagesAndClassesForItem(item) for contentNodeItemValue in contentNodeMessagesAndClasses { let contentNodeItem = contentNodeItemValue as (message: Message, type: AnyClass, attributes: ChatMessageEntryAttributes, bubbleAttributes: BubbleItemAttributes) @@ -1505,6 +1520,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } + var shareButtonOffset: CGPoint? var index = 0 for (message, _, attributes, bubbleAttributes, prepareLayout) in contentPropertiesAndPrepareLayouts { let topPosition: ChatMessageBubbleRelativePosition @@ -1560,6 +1576,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode let (properties, unboundSize, maxNodeWidth, nodeLayout) = prepareLayout(contentItem, layoutConstants, prepareContentPosition, itemSelection, CGSize(width: maximumContentWidth, height: CGFloat.greatestFiniteMagnitude)) maximumNodeWidth = min(maximumNodeWidth, maxNodeWidth) + if let offset = properties.shareButtonOffset { + shareButtonOffset = offset + } + contentPropertiesAndLayouts.append((unboundSize, properties, prepareContentPosition, bubbleAttributes, nodeLayout, needSeparateContainers && !bubbleAttributes.isAttachment ? message.stableId : nil, itemSelection)) switch properties.hidesBackground { @@ -1856,7 +1876,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode headerSize.height += nameNodeSizeApply.0.height } - if !ignoreForward, let forwardInfo = firstMessage.forwardInfo { + if !ignoreForward && !isInstantVideo, let forwardInfo = firstMessage.forwardInfo { if headerSize.height.isZero { headerSize.height += 5.0 } @@ -1889,7 +1909,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode headerSize.height += forwardInfoSizeApply.0.height } - if let replyMessage = replyMessage { + if !isInstantVideo, let replyMessage = replyMessage { if headerSize.height.isZero { headerSize.height += 6.0 } else { @@ -2234,6 +2254,10 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode var reactionButtonsSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)? if let reactionButtonsFinalize = reactionButtonsFinalize { + var maxContentWidth = maxContentWidth + if hasInstantVideo { + maxContentWidth += 64.0 + } reactionButtonsSizeAndApply = reactionButtonsFinalize(maxContentWidth) } @@ -2346,7 +2370,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode contentContainerNodeFrames: contentContainerNodeFrames, mosaicStatusOrigin: mosaicStatusOrigin, mosaicStatusSizeAndApply: mosaicStatusSizeAndApply, - needsShareButton: needsShareButton + needsShareButton: needsShareButton, + shareButtonOffset: shareButtonOffset ) }) } @@ -2390,7 +2415,8 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode contentContainerNodeFrames: [(UInt32, CGRect, Bool?, CGFloat)], mosaicStatusOrigin: CGPoint?, mosaicStatusSizeAndApply: (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)?, - needsShareButton: Bool + needsShareButton: Bool, + shareButtonOffset: CGPoint? ) -> Void { guard let strongSelf = selfReference.value else { return @@ -2826,6 +2852,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode containerSupernode.addSubnode(contentNode) contentNode.bubbleBackgroundNode = strongSelf.backgroundNode + contentNode.bubbleBackdropNode = strongSelf.backgroundWallpaperNode contentNode.updateIsTextSelectionActive = { [weak contextSourceNode] value in contextSourceNode?.updateDistractionFreeMode?(value) } @@ -2858,6 +2885,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.contentNodes = sortedContentNodes } + var shouldClipOnTransitions = true var contentNodeIndex = 0 for (relativeFrame, _, useContentOrigin, apply) in contentNodeFramesPropertiesAndApply { apply(animation, synchronousLoads, applyInfo) @@ -2867,9 +2895,14 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } let contentNode = strongSelf.contentNodes[contentNodeIndex] + + if contentNode.disablesClipping { + shouldClipOnTransitions = false + } + let contentNodeFrame = relativeFrame.offsetBy(dx: contentOrigin.x, dy: useContentOrigin ? contentOrigin.y : 0.0) let previousContentNodeFrame = contentNode.frame - + if case let .System(duration, _) = animation { var animateFrame = false var animateAlpha = false @@ -3034,6 +3067,11 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } } + var isCurrentlyPlayingMedia = false + if item.associatedData.currentlyPlayingMessageId == item.message.index { + isCurrentlyPlayingMedia = true + } + if case let .System(duration, _) = animation/*, !strongSelf.mainContextSourceNode.isExtractedToContextPreview*/ { if !strongSelf.backgroundNode.frame.equalTo(backgroundFrame) { if useDisplayLinkAnimations { @@ -3052,7 +3090,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } else { animation.animator.updateFrame(layer: strongSelf.backgroundNode.layer, frame: backgroundFrame, completion: nil) animation.animator.updatePosition(layer: strongSelf.clippingNode.layer, position: backgroundFrame.center, completion: nil) - strongSelf.clippingNode.clipsToBounds = true + strongSelf.clippingNode.clipsToBounds = shouldClipOnTransitions animation.animator.updateBounds(layer: strongSelf.clippingNode.layer, bounds: CGRect(origin: CGPoint(x: backgroundFrame.minX, y: backgroundFrame.minY), size: backgroundFrame.size), completion: { [weak strongSelf] _ in strongSelf?.clippingNode.clipsToBounds = false }) @@ -3075,7 +3113,15 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode if let shareButtonNode = strongSelf.shareButtonNode { let currentBackgroundFrame = strongSelf.backgroundNode.frame let buttonSize = shareButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: item.message, account: item.context.account, disableComments: true) - animation.animator.updateFrame(layer: shareButtonNode.layer, frame: CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize), completion: nil) + + var buttonFrame = CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) + if let shareButtonOffset = shareButtonOffset { + buttonFrame = buttonFrame.offsetBy(dx: shareButtonOffset.x, dy: shareButtonOffset.y) + } + + animation.animator.updateFrame(layer: shareButtonNode.layer, frame: buttonFrame, completion: nil) + animation.animator.updateAlpha(layer: shareButtonNode.layer, alpha: isCurrentlyPlayingMedia ? 0.0 : 1.0, completion: nil) + } } else { /*if let _ = strongSelf.backgroundFrameTransition { @@ -3085,7 +3131,13 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode strongSelf.messageAccessibilityArea.frame = backgroundFrame if let shareButtonNode = strongSelf.shareButtonNode { let buttonSize = shareButtonNode.update(presentationData: item.presentationData, controllerInteraction: item.controllerInteraction, chatLocation: item.chatLocation, subject: item.associatedData.subject, message: item.message, account: item.context.account, disableComments: true) - shareButtonNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) + + var buttonFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - buttonSize.width - 1.0), size: buttonSize) + if let shareButtonOffset = shareButtonOffset { + buttonFrame = buttonFrame.offsetBy(dx: shareButtonOffset.x, dy: shareButtonOffset.y) + } + shareButtonNode.frame = buttonFrame + shareButtonNode.alpha = isCurrentlyPlayingMedia ? 0.0 : 1.0 } if case .System = animation, strongSelf.mainContextSourceNode.isExtractedToContextPreview { diff --git a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift index e898b902a9..6f298c0e3b 100644 --- a/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageFileBubbleContentNode.swift @@ -6,6 +6,8 @@ import SwiftSignalKit import Postbox import TelegramCore import TelegramUIPreferences +import ComponentFlow +import AudioTranscriptionButtonComponent class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { let interactiveFileNode: ChatMessageInteractiveFileNode @@ -77,7 +79,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { self?.updateIsTextSelectionActive?(value) } } - + override func accessibilityActivate() -> Bool { if let item = self.item { let _ = item.controllerInteraction.openMessage(item.message, .default) diff --git a/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift b/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift new file mode 100644 index 0000000000..46b1f369db --- /dev/null +++ b/submodules/TelegramUI/Sources/ChatMessageInstantVideoBubbleContentNode.swift @@ -0,0 +1,408 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import TelegramUIPreferences +import ComponentFlow +import AudioTranscriptionButtonComponent + +class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentNode { + let interactiveFileNode: ChatMessageInteractiveFileNode + let interactiveVideoNode: ChatMessageInteractiveInstantVideoNode + + private let maskLayer = SimpleLayer() + private let maskForeground = SimpleLayer() + + private let backdropMaskLayer = SimpleLayer() + private let backdropMaskForeground = SimpleShapeLayer() + + private var isExpanded = false + + private var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .collapsed + + override var visibility: ListViewItemNodeVisibility { + didSet { + var wasVisible = false + if case .visible = oldValue { + wasVisible = true + } + var isVisible = false + if case .visible = self.visibility { + isVisible = true + } + if wasVisible != isVisible { + self.interactiveVideoNode.visibility = isVisible + } + } + } + + required init() { + self.interactiveFileNode = ChatMessageInteractiveFileNode() + self.interactiveVideoNode = ChatMessageInteractiveInstantVideoNode() + + super.init() + + self.maskForeground.backgroundColor = UIColor.white.cgColor + self.maskForeground.masksToBounds = true + self.maskLayer.addSublayer(self.maskForeground) + + self.backdropMaskForeground.fillColor = UIColor.white.cgColor + self.backdropMaskForeground.masksToBounds = true + + self.addSubnode(self.interactiveFileNode) + self.addSubnode(self.interactiveVideoNode) + + self.interactiveVideoNode.requestUpdateLayout = { [weak self] _ in + if let strongSelf = self, let item = strongSelf.item { + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id) + } + } + self.interactiveVideoNode.updateTranscribeExpanded = { [weak self] state, text in + if let strongSelf = self, let item = strongSelf.item { + strongSelf.audioTranscriptionState = state + strongSelf.interactiveFileNode.audioTranscriptionState = state + strongSelf.interactiveFileNode.forcedAudioTranscriptionText = text + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id) + } + } + self.interactiveFileNode.updateTranscribeExpanded = { [weak self] state in + if let strongSelf = self, let item = strongSelf.item { + strongSelf.audioTranscriptionState = state + strongSelf.interactiveVideoNode.audioTranscriptionState = state + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id) + } + } + + self.interactiveFileNode.toggleSelection = { [weak self] value in + if let strongSelf = self, let item = strongSelf.item { + item.controllerInteraction.toggleMessagesSelection([item.message.id], value) + } + } + + self.interactiveFileNode.activateLocalContent = { [weak self] in + if let strongSelf = self, let item = strongSelf.item { + let _ = item.controllerInteraction.openMessage(item.message, .default) + } + } + + self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in + if let strongSelf = self, let item = strongSelf.item { + let _ = item.controllerInteraction.requestMessageUpdate(item.message.id) + } + } + + self.interactiveFileNode.displayImportedTooltip = { [weak self] sourceNode in + if let strongSelf = self, let item = strongSelf.item { + let _ = item.controllerInteraction.displayImportedMessageTooltip(sourceNode) + } + } + + self.interactiveFileNode.dateAndStatusNode.reactionSelected = { [weak self] value in + guard let strongSelf = self, let item = strongSelf.item else { + return + } + item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) + } + + self.interactiveFileNode.dateAndStatusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in + guard let strongSelf = self, let item = strongSelf.item else { + gesture?.cancel() + return + } + + item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value) + } + + self.interactiveFileNode.updateIsTextSelectionActive = { [weak self] value in + self?.updateIsTextSelectionActive?(value) + } + } + + override func accessibilityActivate() -> Bool { + if let item = self.item { + let _ = item.controllerInteraction.openMessage(item.message, .default) + } + return true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { + let interactiveVideoLayout = self.interactiveVideoNode.asyncLayout() + let interactiveFileLayout = self.interactiveFileNode.asyncLayout() + + let currentExpanded = self.isExpanded + let audioTranscriptionState = self.audioTranscriptionState + let didSetupFileNode = self.item != nil + + return { item, layoutConstants, preparePosition, selection, constrainedSize in + var selectedFile: TelegramMediaFile? + for media in item.message.media { + if let telegramFile = media as? TelegramMediaFile { + selectedFile = telegramFile + } + } + + let incoming = item.message.effectivelyIncoming(item.context.account.peerId) + let statusType: ChatMessageDateAndStatusType? + switch preparePosition { + case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): + if incoming { + statusType = .BubbleIncoming + } else { + if item.message.flags.contains(.Failed) { + statusType = .BubbleOutgoing(.Failed) + } else if (item.message.flags.isSending && !item.message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { + statusType = .BubbleOutgoing(.Sending) + } else { + statusType = .BubbleOutgoing(.Sent(read: item.read)) + } + } + default: + statusType = nil + } + + let automaticDownload = shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: selectedFile!) + + let (_, refineLayout) = interactiveFileLayout(ChatMessageInteractiveFileNode.Arguments( + context: item.context, + presentationData: item.presentationData, + message: item.message, + topMessage: item.topMessage, + associatedData: item.associatedData, + chatLocation: item.chatLocation, + attributes: item.attributes, + isPinned: item.isItemPinned, + forcedIsEdited: item.isItemEdited, + file: selectedFile!, + automaticDownload: automaticDownload, + incoming: item.message.effectivelyIncoming(item.context.account.peerId), + isRecentActions: item.associatedData.isRecentActions, + forcedResourceStatus: item.associatedData.forcedResourceStatus, + dateAndStatusType: statusType, + displayReactions: false, + messageSelection: item.message.groupingKey != nil ? selection : nil, + layoutConstants: layoutConstants, + constrainedSize: CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right, height: constrainedSize.height), + controllerInteraction: item.controllerInteraction + )) + + var isReplyThread = false + if case .replyThread = item.chatLocation { + isReplyThread = true + } + + let avatarInset: CGFloat = 0.0 + + var isExpanded = false + if case .expanded = audioTranscriptionState { + isExpanded = true + } + + var isPlaying = false + let normalDisplaySize = layoutConstants.instantVideo.dimensions + var displaySize = normalDisplaySize + let maximumDisplaySize = CGSize(width: min(404, constrainedSize.width - 2.0), height: min(404, constrainedSize.width - 2.0)) +// var effectiveAvatarInset = avatarInset + if item.associatedData.currentlyPlayingMessageId == item.message.index { + isPlaying = true + if !isExpanded { + displaySize = maximumDisplaySize + } +// effectiveAvatarInset = 0.0 + } + + let leftInset: CGFloat = 0.0 + let rightInset: CGFloat = 0.0 + + + + let (videoLayout, videoApply) = interactiveVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, topMessage: item.message, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.attributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), constrainedSize.width - leftInset - rightInset - avatarInset, displaySize, maximumDisplaySize, isPlaying ? 1.0 : 0.0, .free, automaticDownload) + + let videoFrame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: videoLayout.contentSize) + + let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none, shareButtonOffset: isExpanded ? .zero : CGPoint(x: -16.0, y: -24.0)) + + let width = videoFrame.width + 2.0 +// if isExpanded { +// width = normalDisplaySize.width + 32.0 +// } + + return (contentProperties, nil, width, { constrainedSize, position in + var refinedWidth = videoFrame.width + 2.0 + var finishLayout: ((CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation, ListViewItemApply?) -> Void))? + + if isExpanded || !didSetupFileNode { + (refinedWidth, finishLayout) = refineLayout(CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right, height: constrainedSize.height)) +// refinedWidth = refinedWidth//max(refinedWidth, normalDisplaySize.width) + refinedWidth += layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right + } + + if !isExpanded { + refinedWidth = videoFrame.width + 2.0 + } + + return (refinedWidth, { boundingWidth in + var finalSize: CGSize + var finalFileSize: CGSize? + var finalFileApply: ((Bool, ListViewItemUpdateAnimation, ListViewItemApply?) -> Void)? + if let finishLayout = finishLayout { + let (fileSize, fileApply) = finishLayout(boundingWidth - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right) + if isExpanded { + finalSize = CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + layoutConstants.file.bubbleInsets.bottom) + } else { + finalSize = CGSize(width: boundingWidth, height: videoFrame.height + 2.0) + } + finalFileSize = fileSize + finalFileApply = fileApply + } else { + finalSize = CGSize(width: boundingWidth, height: videoFrame.height + 2.0) + } + + return (finalSize, { [weak self] animation, synchronousLoads, applyInfo in + if let strongSelf = self { + let firstTime = strongSelf.item == nil + strongSelf.item = item + strongSelf.isExpanded = isExpanded + + if currentExpanded != isExpanded { + item.controllerInteraction.scrollToMessageId(item.message.index) + } + + if firstTime { + strongSelf.interactiveFileNode.isHidden = true + } + + strongSelf.bubbleBackgroundNode?.layer.mask = strongSelf.maskLayer + if let bubbleBackdropNode = strongSelf.bubbleBackdropNode, bubbleBackdropNode.hasImage && strongSelf.backdropMaskForeground.superlayer == nil { + strongSelf.bubbleBackdropNode?.overrideMask = true + strongSelf.bubbleBackdropNode?.maskView?.layer.addSublayer(strongSelf.backdropMaskForeground) + } + + strongSelf.maskLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 640.0, height: 640.0)) + strongSelf.backdropMaskLayer.frame = strongSelf.maskLayer.frame + + let radius: CGFloat = displaySize.width / 2.0 + let maskCornerRadius = isExpanded ? 1.0 : radius + let maskFrame = CGRect(origin: CGPoint(x: isExpanded ? 1.0 : (incoming ? 7.0 : 1.0), y: isExpanded ? 0.0 : 1.0), size: isExpanded ? finalSize : CGSize(width: radius * 2.0, height: radius * 2.0)) + animation.animator.updateCornerRadius(layer: strongSelf.maskForeground, cornerRadius: maskCornerRadius, completion: nil) + animation.animator.updateFrame(layer: strongSelf.maskForeground, frame: maskFrame, completion: nil) + + let backdropMaskFrame = CGRect(origin: CGPoint(x: isExpanded ? (incoming ? 8.0 : 2.0) : (incoming ? 8.0 : 2.0), y: isExpanded ? 2.0 : 2.0), size: isExpanded ? CGSize(width: finalSize.width - 11.0, height: finalSize.height - 2.0) : CGSize(width: radius * 2.0, height: radius * 2.0)) + +// let auxiliaryRadius = item.presentationData.chatBubbleCorners.auxiliaryRadius + let backdropRadius = isExpanded ? item.presentationData.chatBubbleCorners.mainRadius : radius + let path = CGPath(roundedRect: backdropMaskFrame, cornerWidth: backdropRadius, cornerHeight: backdropRadius, transform: nil) + strongSelf.backdropMaskForeground.frame = strongSelf.maskLayer.frame + animation.transition.updatePath(layer: strongSelf.backdropMaskForeground, path: path) + + + let videoLayoutData: ChatMessageInstantVideoItemLayoutData + if incoming { + videoLayoutData = .constrained(left: 0.0, right: 0.0) //max(0.0, availableContentWidth - videoFrame.width)) + } else { + videoLayoutData = .constrained(left: 0.0, right: 0.0) + } + + var videoAnimation = animation + if currentExpanded != isExpanded { + videoAnimation = .None + } + + animation.animator.updateFrame(layer: strongSelf.interactiveVideoNode.layer, frame: videoFrame, completion: nil) + videoApply(videoLayoutData, videoAnimation) + + if let fileSize = finalFileSize { + strongSelf.interactiveFileNode.frame = CGRect(origin: CGPoint(x: layoutConstants.file.bubbleInsets.left, y: layoutConstants.file.bubbleInsets.top), size: fileSize) + finalFileApply?(synchronousLoads, .None, applyInfo) + } + + if currentExpanded != isExpanded { + if isExpanded { + strongSelf.interactiveVideoNode.animateTo(strongSelf.interactiveFileNode, animator: animation.animator) + } else { + strongSelf.interactiveVideoNode.animateFrom(strongSelf.interactiveFileNode, animator: animation.animator) + } + } + } + }) + }) + }) + } + } + + override func transitionNode(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { + return nil + } + + override func updateHiddenMedia(_ media: [Media]?) -> Bool { + return false + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double) { + self.interactiveVideoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override func animateAdded(_ currentTimestamp: Double, duration: Double) { + self.interactiveVideoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.interactiveVideoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + override func willUpdateIsExtractedToContextPreview(_ value: Bool) { +// self.interactiveFileNode.willUpdateIsExtractedToContextPreview(value) + } + + override func updateIsExtractedToContextPreview(_ value: Bool) { +// self.interactiveFileNode.updateIsExtractedToContextPreview(value) + } + + override func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { + if !self.interactiveFileNode.isHidden { + if self.interactiveFileNode.dateAndStatusNode.supernode != nil, let _ = self.interactiveFileNode.dateAndStatusNode.hitTest(self.view.convert(point, to: self.interactiveFileNode.dateAndStatusNode.view), with: nil) { + return .ignore + } + if self.interactiveFileNode.hasTapAction(at: self.view.convert(point, to: self.interactiveFileNode.view)) { + return .ignore + } + } + if !self.interactiveVideoNode.isHidden { + if self.interactiveVideoNode.dateAndStatusNode.supernode != nil, let _ = self.interactiveVideoNode.dateAndStatusNode.hitTest(self.view.convert(point, to: self.interactiveVideoNode.dateAndStatusNode.view), with: nil) { + return .ignore + } + if let audioTranscriptionButton = self.interactiveVideoNode.audioTranscriptionButton, let _ = audioTranscriptionButton.hitTest(self.view.convert(point, to: audioTranscriptionButton), with: nil) { + return .ignore + } + } + return super.tapActionAtPoint(point, gesture: gesture, isEstimating: isEstimating) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.isExpanded, let result = self.interactiveFileNode.hitTest(self.view.convert(point, to: self.interactiveFileNode.view), with: event) { + return result + } + if !self.isExpanded, let result = self.interactiveVideoNode.hitTest(self.view.convert(point, to: self.interactiveVideoNode.view), with: event) { + return result + } + return super.hitTest(point, with: event) + } + + override func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { + if !self.interactiveVideoNode.dateAndStatusNode.isHidden { + return self.interactiveVideoNode.dateAndStatusNode.reactionView(value: value) + } + return nil + } + + override var disablesClipping: Bool { + return true + } +} diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift index f0cb9b3d92..b477898f58 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveFileNode.swift @@ -32,12 +32,12 @@ private struct FetchControls { let cancel: () -> Void } -private enum TranscribedText { +enum TranscribedText { case success(text: String, isPending: Bool) case error(AudioTranscriptionMessageAttribute.TranscriptionError) } -private func transcribedText(message: Message) -> TranscribedText? { +func transcribedText(message: Message) -> TranscribedText? { for attribute in message.attributes { if let attribute = attribute as? AudioTranscriptionMessageAttribute { if !attribute.text.isEmpty { @@ -127,10 +127,10 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { private let titleNode: TextNode private let descriptionNode: TextNode private let descriptionMeasuringNode: TextNode - private let fetchingTextNode: ImmediateTextNode - private let fetchingCompactTextNode: ImmediateTextNode + let fetchingTextNode: ImmediateTextNode + let fetchingCompactTextNode: ImmediateTextNode - private var waveformView: ComponentHostView? + var waveformView: ComponentHostView? /*private let waveformNode: AudioWaveformNode private let waveformForegroundNode: AudioWaveformNode @@ -138,9 +138,9 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { private var waveformMaskNode: AudioWaveformNode? private var waveformScrubbingNode: MediaPlayerScrubbingNode?*/ - private var audioTranscriptionButton: ComponentHostView? + var audioTranscriptionButton: ComponentHostView? private var transcriptionPendingIndicator: ComponentHostView? - private let textNode: TextNode + let textNode: TextNode private let textClippingNode: ASDisplayNode private var textSelectionNode: TextSelectionNode? @@ -151,7 +151,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { private var iconNode: TransformImageNode? let statusContainerNode: ContextExtractedContentContainingNode - private var statusNode: SemanticStatusNode? + var statusNode: SemanticStatusNode? private var playbackAudioLevelNode: VoiceBlobNode? private var streamingStatusNode: SemanticStatusNode? private var tapRecognizer: UITapGestureRecognizer? @@ -199,6 +199,8 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { var requestUpdateLayout: (Bool) -> Void = { _ in } var displayImportedTooltip: (ASDisplayNode) -> Void = { _ in } + var updateTranscribeExpanded: ((AudioTranscriptionButtonComponent.TranscriptionState) -> Void)? + private var context: AccountContext? private var message: Message? private var arguments: Arguments? @@ -208,7 +210,8 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { private var streamingCacheStatusFrame: CGRect? private var fileIconImage: UIImage? - private var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .collapsed + var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .collapsed + var forcedAudioTranscriptionText: TranscribedText? private var transcribeDisposable: Disposable? var hasExpandedAudioTranscription: Bool { if case .expanded = audioTranscriptionState { @@ -444,6 +447,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { self.audioTranscriptionState = .collapsed self.isWaitingForCollapse = true self.requestUpdateLayout(true) + self.updateTranscribeExpanded?(self.audioTranscriptionState) case .collapsed: self.audioTranscriptionState = .inProgress self.requestUpdateLayout(true) @@ -464,6 +468,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { let currentMessage = self.message let audioTranscriptionState = self.audioTranscriptionState + let forcedAudioTranscriptionText = self.forcedAudioTranscriptionText return { arguments in return (CGFloat.greatestFiniteMagnitude, { constrainedSize in @@ -489,7 +494,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { statusUpdated = true } - let hasThumbnail = (!arguments.file.previewRepresentations.isEmpty || arguments.file.immediateThumbnailData != nil) && !arguments.file.isMusic && !arguments.file.isVoice + let hasThumbnail = (!arguments.file.previewRepresentations.isEmpty || arguments.file.immediateThumbnailData != nil) && !arguments.file.isMusic && !arguments.file.isVoice && !arguments.file.isInstantVideo if mediaUpdated { if largestImageRepresentation(arguments.file.previewRepresentations) != nil || arguments.file.immediateThumbnailData != nil { @@ -549,10 +554,20 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { let messageTheme = arguments.incoming ? arguments.presentationData.theme.theme.chat.message.incoming : arguments.presentationData.theme.theme.chat.message.outgoing + let isInstantVideo = arguments.file.isInstantVideo for attribute in arguments.file.attributes { + if case let .Video(videoDuration, _, flags) = attribute, flags.contains(.instantRoundVideo) { + isAudio = true + isVoice = true + + let durationString = stringForDuration(Int32(videoDuration)) + candidateDescriptionString = NSAttributedString(string: durationString, font: durationFont, textColor: messageTheme.fileDurationColor) + } if case let .Audio(voice, duration, title, performer, waveform) = attribute { isAudio = true + let voice = voice || isInstantVideo + if let forcedResourceStatus = arguments.forcedResourceStatus, statusUpdated { updatedStatusSignal = .single((forcedResourceStatus, nil)) } else if let currentUpdatedStatusSignal = updatedStatusSignal { @@ -591,7 +606,6 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } candidateDescriptionString = NSAttributedString(string: descriptionText, font: descriptionFont, textColor: messageTheme.fileDescriptionColor) } - break } } @@ -639,7 +653,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { var textString: NSAttributedString? var updatedAudioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState? - let transcribedText = transcribedText(message: arguments.message) + let transcribedText = forcedAudioTranscriptionText ?? transcribedText(message: arguments.message) switch audioTranscriptionState { case .inProgress: @@ -1352,13 +1366,16 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { var isVoice = false var audioDuration: Int32? for attribute in file.attributes { - if case let .Audio(voice, duration, _, _, _) = attribute { + if case let .Video(duration, _, flags) = attribute, flags.contains(.instantRoundVideo) { + isAudio = true + isVoice = true + audioDuration = Int32(duration) + } else if case let .Audio(voice, duration, _, _, _) = attribute { isAudio = true if voice { isVoice = true audioDuration = Int32(duration) } - break } } @@ -1432,7 +1449,7 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { } } case .Local: - if isAudio { + if isAudio { state = .play } else if let fileIconImage = self.fileIconImage { state = .customIcon(fileIconImage) @@ -1786,6 +1803,10 @@ final class ChatMessageInteractiveFileNode: ASDisplayNode { view.animateIn() } } + + func animateTo(_ node: ChatMessageInteractiveInstantVideoNode) { + + } } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift index 2b7a56f7e6..50ed58b355 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveInstantVideoNode.swift @@ -13,6 +13,8 @@ import PhotoResources import TelegramUniversalVideoContent import FileMediaResourceStatus import HierarchyTrackingLayer +import ComponentFlow +import AudioTranscriptionButtonComponent struct ChatMessageInstantVideoItemLayoutResult { let contentSize: CGSize @@ -44,14 +46,27 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } } + var customIsHidden: Bool = false { + didSet { + if self.customIsHidden != oldValue { + Queue.mainQueue().justDispatch { + self.videoNode?.canAttachContent = self.shouldAcquireVideoContext + } + } + } + } + private var videoNode: UniversalVideoNode? private let secretVideoPlaceholderBackground: ASImageNode private let secretVideoPlaceholder: TransformImageNode + var audioTranscriptionButton: ComponentHostView? + private var statusNode: RadialStatusNode? private var disappearingStatusNode: RadialStatusNode? private var playbackStatusNode: InstantVideoRadialStatusNode? private(set) var videoFrame: CGRect? + private var imageScale: CGFloat = 1.0 private var item: ChatMessageBubbleContentItem? private var automaticDownload: Bool? @@ -80,7 +95,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { private let fetchedThumbnailDisposable = MetaDisposable() private var shouldAcquireVideoContext: Bool { - if self.visibility && self.trackingIsInHierarchy { + if self.visibility && self.trackingIsInHierarchy && !self.customIsHidden { return true } else { return false @@ -97,6 +112,20 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { var shouldOpen: () -> Bool = { return true } + var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .collapsed + var audioTranscriptionText: TranscribedText? + private var transcribeDisposable: Disposable? + var hasExpandedAudioTranscription: Bool { + if case .expanded = audioTranscriptionState { + return true + } else { + return false + } + } + private var isWaitingForCollapse: Bool = false + + var requestUpdateLayout: (Bool) -> Void = { _ in } + override init() { self.secretVideoPlaceholderBackground = ASImageNode() self.secretVideoPlaceholderBackground.isLayerBacked = true @@ -138,7 +167,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { super.didLoad() let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) - recognizer.tapActionAtPoint = { _ in + recognizer.tapActionAtPoint = { point in return .waitForSingleTap } self.view.addGestureRecognizer(recognizer) @@ -169,6 +198,8 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { let makeDateAndStatusLayout = self.dateAndStatusNode.asyncLayout() + let audioTranscriptionState = self.audioTranscriptionState + return { item, width, displaySize, maximumDisplaySize, scaleProgress, statusDisplayType, automaticDownload in var secretVideoPlaceholderBackgroundImage: UIImage? var updatedInfoBackgroundImage: UIImage? @@ -176,9 +207,10 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { var updatedInstantVideoBackgroundImage: UIImage? let instantVideoBackgroundImage: UIImage? + switch statusDisplayType { case .free: - instantVideoBackgroundImage = PresentationResourcesChat.chatInstantVideoBackgroundImage(item.presentationData.theme.theme, wallpaper: !item.presentationData.theme.wallpaper.isEmpty) + instantVideoBackgroundImage = nil case .bubble: instantVideoBackgroundImage = nil } @@ -355,12 +387,32 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { let result = ChatMessageInstantVideoItemLayoutResult(contentSize: contentSize, overflowLeft: 0.0, overflowRight: dateAndStatusOverflow ? 0.0 : (max(0.0, floorToScreenPixels(videoFrame.midX) + 55.0 + dateAndStatusSize.width - videoFrame.width))) + var updatedAudioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState? + let transcribedText = transcribedText(message: item.message) + + switch audioTranscriptionState { + case .inProgress: + if transcribedText != nil { + updatedAudioTranscriptionState = .expanded + } + default: + break + } + + let effectiveAudioTranscriptionState = updatedAudioTranscriptionState ?? audioTranscriptionState + return (result, { [weak self] layoutData, animation in if let strongSelf = self { strongSelf.item = item strongSelf.videoFrame = displayVideoFrame strongSelf.secretProgressIcon = secretProgressIcon strongSelf.automaticDownload = automaticDownload + + if let updatedAudioTranscriptionState = updatedAudioTranscriptionState { + strongSelf.audioTranscriptionState = updatedAudioTranscriptionState + strongSelf.audioTranscriptionText = transcribedText + strongSelf.updateTranscribeExpanded?(updatedAudioTranscriptionState, transcribedText) + } if let updatedInfoBackgroundImage = updatedInfoBackgroundImage { strongSelf.infoBackgroundNode.image = updatedInfoBackgroundImage @@ -383,7 +435,8 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { if let infoBackgroundImage = strongSelf.infoBackgroundNode.image, let muteImage = strongSelf.muteIconNode.image { let infoWidth = muteImage.size.width let infoBackgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(displayVideoFrame.minX + (displayVideoFrame.size.width - infoWidth) / 2.0), y: displayVideoFrame.maxY - infoBackgroundImage.size.height - 8.0), size: CGSize(width: infoWidth, height: infoBackgroundImage.size.height)) - strongSelf.infoBackgroundNode.frame = infoBackgroundFrame + animation.animator.updateFrame(layer: strongSelf.infoBackgroundNode.layer, frame: infoBackgroundFrame, completion: nil) + let muteIconFrame = CGRect(origin: CGPoint(x: infoBackgroundFrame.width - muteImage.size.width, y: 0.0), size: muteImage.size) strongSelf.muteIconNode.frame = muteIconFrame } @@ -395,40 +448,26 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { strongSelf.fetchedThumbnailDisposable.set(nil) } } - - dateAndStatusApply(animation) - switch layoutData { - case let .unconstrained(width): - let dateAndStatusOrigin: CGPoint - if dateAndStatusOverflow { - dateAndStatusOrigin = CGPoint(x: displayVideoFrame.minX - 4.0, y: displayVideoFrame.maxY + 2.0) - } else { - dateAndStatusOrigin = CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, width - dateAndStatusSize.width - 4.0), y: displayVideoFrame.height - dateAndStatusSize.height) - } - animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: CGRect(origin: dateAndStatusOrigin, size: dateAndStatusSize), completion: nil) - case let .constrained(_, right): - animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: CGRect(origin: CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, displayVideoFrame.maxX + right - dateAndStatusSize.width - 4.0), y: displayVideoFrame.maxY - dateAndStatusSize.height), size: dateAndStatusSize), completion: nil) + + var durationBlurColor: (UIColor, Bool)? + let durationTextColor: UIColor + switch statusDisplayType { + case .free: + let serviceColor = serviceMessageColorComponents(theme: theme.theme, wallpaper: theme.wallpaper) + durationTextColor = serviceColor.primaryText + durationBlurColor = (selectDateFillStaticColor(theme: theme.theme, wallpaper: theme.wallpaper), dateFillNeedsBlur(theme: theme.theme, wallpaper: theme.wallpaper)) + case .bubble: + durationBlurColor = nil + if item.message.effectivelyIncoming(item.context.account.peerId) { + durationTextColor = theme.theme.chat.message.incoming.secondaryTextColor + } else { + durationTextColor = theme.theme.chat.message.outgoing.secondaryTextColor + } } - + var updatedPlayerStatusSignal: Signal? if let telegramFile = updatedFile { if updatedMedia { - let durationTextColor: UIColor - let durationBlurColor: (UIColor, Bool)? - switch statusDisplayType { - case .free: - let serviceColor = serviceMessageColorComponents(theme: theme.theme, wallpaper: theme.wallpaper) - durationTextColor = serviceColor.primaryText - durationBlurColor = (selectDateFillStaticColor(theme: theme.theme, wallpaper: theme.wallpaper), dateFillNeedsBlur(theme: theme.theme, wallpaper: theme.wallpaper)) - case .bubble: - durationBlurColor = nil - if item.message.effectivelyIncoming(item.context.account.peerId) { - durationTextColor = theme.theme.chat.message.incoming.secondaryTextColor - } else { - durationTextColor = theme.theme.chat.message.outgoing.secondaryTextColor - } - } - if let durationBlurColor = durationBlurColor { if let durationBackgroundNode = strongSelf.durationBackgroundNode { durationBackgroundNode.updateColor(color: durationBlurColor.0, enableBlur: durationBlurColor.1, transition: .immediate) @@ -525,28 +564,114 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { })) } + let incoming = item.message.effectivelyIncoming(item.context.account.peerId) + + var canTranscribe = statusDisplayType == .free && item.associatedData.isPremium && item.message.id.peerId.namespace != Namespaces.Peer.SecretChat + if canTranscribe, let durationBlurColor = durationBlurColor { + let audioTranscriptionButton: ComponentHostView + if let current = strongSelf.audioTranscriptionButton { + audioTranscriptionButton = current + } else { + audioTranscriptionButton = ComponentHostView() + strongSelf.audioTranscriptionButton = audioTranscriptionButton + strongSelf.view.addSubview(audioTranscriptionButton) + } + let audioTranscriptionButtonSize = audioTranscriptionButton.update( + transition: animation.isAnimated ? .easeInOut(duration: 0.3) : .immediate, + component: AnyComponent(AudioTranscriptionButtonComponent( + theme: .freeform(durationBlurColor), + transcriptionState: effectiveAudioTranscriptionState, + pressed: { + guard let strongSelf = self else { + return + } + strongSelf.transcribe() + } + )), + environment: {}, + containerSize: CGSize(width: 30.0, height: 30.0) + ) + + var audioTranscriptionButtonFrame: CGRect + if incoming { + audioTranscriptionButtonFrame = CGRect(origin: CGPoint(x: displayVideoFrame.maxX - 30.0, y: displayVideoFrame.maxY - 30.0), size: audioTranscriptionButtonSize) + if !scaleProgress.isZero { + audioTranscriptionButtonFrame.origin.x = displayVideoFrame.midX + 43.0 + } + } else { + audioTranscriptionButtonFrame = CGRect(origin: CGPoint(x: displayVideoFrame.minX, y: displayVideoFrame.maxY - 30.0), size: audioTranscriptionButtonSize) + if !scaleProgress.isZero { + audioTranscriptionButtonFrame.origin.x = displayVideoFrame.midX - 74.0 + } + } + + animation.animator.updateFrame(layer: audioTranscriptionButton.layer, frame: audioTranscriptionButtonFrame, completion: nil) + + animation.animator.updateAlpha(layer: audioTranscriptionButton.layer, alpha: scaleProgress.isZero ? 1.0 : 0.0, completion: nil) + if !scaleProgress.isZero { + canTranscribe = false + } + } else { + if let audioTranscriptionButton = strongSelf.audioTranscriptionButton { + strongSelf.audioTranscriptionButton = nil + audioTranscriptionButton.removeFromSuperview() + } + } + if let durationNode = strongSelf.durationNode { - durationNode.frame = CGRect(origin: CGPoint(x: displayVideoFrame.midX - 56.0 - 25.0 * scaleProgress, y: displayVideoFrame.maxY - 18.0), size: CGSize(width: 1.0, height: 1.0)) + var durationFrame = CGRect(origin: CGPoint(x: displayVideoFrame.midX - 56.0 - 25.0 * scaleProgress, y: displayVideoFrame.maxY - 18.0), size: CGSize(width: 1.0, height: 1.0)) + animation.animator.updateFrame(layer: durationNode.layer, frame: durationFrame, completion: nil) + durationNode.isSeen = !notConsumed let size = durationNode.size if let durationBackgroundNode = strongSelf.durationBackgroundNode, size.width > 1.0 { - durationBackgroundNode.frame = CGRect(origin: CGPoint(x: durationNode.frame.maxX - size.width, y: durationNode.frame.minY), size: size) durationBackgroundNode.update(size: size, cornerRadius: size.height / 2.0, transition: .immediate) + + if !incoming, let audioTranscriptionButton = strongSelf.audioTranscriptionButton, canTranscribe { + durationFrame.origin.x = audioTranscriptionButton.frame.minX - 7.0 + } + animation.animator.updateFrame(layer: durationNode.layer, frame: durationFrame, completion: nil) + animation.animator.updateFrame(layer: durationBackgroundNode.layer, frame: CGRect(origin: CGPoint(x: durationNode.frame.maxX - size.width, y: durationNode.frame.minY), size: size), completion: nil) } } + dateAndStatusApply(animation) + switch layoutData { + case let .unconstrained(width): + var dateAndStatusOrigin: CGPoint + if dateAndStatusOverflow { + dateAndStatusOrigin = CGPoint(x: displayVideoFrame.minX - 4.0, y: displayVideoFrame.maxY + 2.0) + } else { + dateAndStatusOrigin = CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, width - dateAndStatusSize.width - 4.0), y: displayVideoFrame.height - dateAndStatusSize.height) + if !incoming, let audioTranscriptionButton = strongSelf.audioTranscriptionButton, canTranscribe { + dateAndStatusOrigin.x = audioTranscriptionButton.frame.maxX + 7.0 + } + } + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: CGRect(origin: dateAndStatusOrigin, size: dateAndStatusSize), completion: nil) + case let .constrained(_, right): + var dateAndStatusFrame = CGRect(origin: CGPoint(x: min(floorToScreenPixels(displayVideoFrame.midX) + 55.0 + 25.0 * scaleProgress, displayVideoFrame.maxX + right - dateAndStatusSize.width - 4.0), y: displayVideoFrame.maxY - dateAndStatusSize.height), size: dateAndStatusSize) + if incoming, let audioTranscriptionButton = strongSelf.audioTranscriptionButton, canTranscribe { + dateAndStatusFrame.origin.x = audioTranscriptionButton.frame.maxX + 7.0 + } + animation.animator.updateFrame(layer: strongSelf.dateAndStatusNode.layer, frame: dateAndStatusFrame, completion: nil) + } + if let videoNode = strongSelf.videoNode { videoNode.bounds = CGRect(origin: CGPoint(), size: videoFrame.size) - videoNode.transform = CATransform3DMakeScale(imageScale, imageScale, 1.0) - videoNode.position = displayVideoFrame.center - videoNode.updateLayout(size: arguments.boundingSize, transition: .immediate) + if imageScale != strongSelf.imageScale { + strongSelf.imageScale = imageScale + animation.animator.updateScale(layer: videoNode.layer, scale: imageScale, completion: nil) + } + animation.animator.updatePosition(layer: videoNode.layer, position: displayVideoFrame.center, completion: nil) + videoNode.updateLayout(size: arguments.boundingSize, transition: animation.transition) } - strongSelf.secretVideoPlaceholderBackground.frame = displayVideoFrame + animation.animator.updateFrame(layer: strongSelf.secretVideoPlaceholderBackground.layer, frame: displayVideoFrame, completion: nil) let placeholderFrame = videoFrame.insetBy(dx: 2.0, dy: 2.0) strongSelf.secretVideoPlaceholder.bounds = CGRect(origin: CGPoint(), size: videoFrame.size) - strongSelf.secretVideoPlaceholder.transform = CATransform3DMakeScale(imageScale, imageScale, 1.0) - strongSelf.secretVideoPlaceholder.position = displayVideoFrame.center + animation.animator.updateScale(layer: strongSelf.secretVideoPlaceholder.layer, scale: imageScale, completion: nil) + animation.animator.updatePosition(layer: strongSelf.secretVideoPlaceholder.layer, position: displayVideoFrame.center, completion: nil) + let makeSecretPlaceholderLayout = strongSelf.secretVideoPlaceholder.asyncLayout() let arguments = TransformImageArguments(corners: ImageCorners(radius: placeholderFrame.size.width / 2.0), imageSize: placeholderFrame.size, boundingSize: placeholderFrame.size, intrinsicInsets: UIEdgeInsets()) let applySecretPlaceholder = makeSecretPlaceholderLayout(arguments) @@ -748,6 +873,11 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } } self.addSubnode(playbackStatusNode) + + if let audioTranscriptionButton = self.audioTranscriptionButton { + audioTranscriptionButton.superview?.bringSubviewToFront(audioTranscriptionButton) + } + self.playbackStatusNode = playbackStatusNode } playbackStatusNode.frame = videoFrame.insetBy(dx: 1.5, dy: 1.5) @@ -779,6 +909,11 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap: + if let audioTranscriptionButton = self.audioTranscriptionButton, !audioTranscriptionButton.isHidden, audioTranscriptionButton.frame.contains(location) { + self.transcribe() + return + } + if let statusNode = self.statusNode, statusNode.supernode != nil, !statusNode.isHidden, statusNode.frame.contains(location) { self.progressPressed() return @@ -822,6 +957,9 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { if !self.bounds.contains(point) { return nil } + if let audioTranscriptionButton = self.audioTranscriptionButton, !audioTranscriptionButton.isHidden, audioTranscriptionButton.frame.contains(point) { + return audioTranscriptionButton + } if let playbackNode = self.playbackStatusNode, !self.isPlaying, !playbackNode.frame.insetBy(dx: 0.2 * playbackNode.frame.width, dy: 0.2 * playbackNode.frame.height).contains(point) { let distanceFromCenter = point.distanceTo(playbackNode.position) if distanceFromCenter < 0.2 * playbackNode.frame.width { @@ -833,6 +971,7 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { if let statusNode = self.statusNode, statusNode.supernode != nil, !statusNode.isHidden, statusNode.frame.contains(point) { return self.view } + if let videoNode = self.videoNode, videoNode.frame.contains(point) { return self.view } @@ -990,5 +1129,225 @@ class ChatMessageInteractiveInstantVideoNode: ASDisplayNode { } } } + + var updateTranscribeExpanded: ((AudioTranscriptionButtonComponent.TranscriptionState, TranscribedText?) -> Void)? + private func transcribe() { + guard let context = self.item?.context, let message = self.item?.message else { + return + } + + var shouldBeginTranscription = false + var shouldExpandNow = false + + if case .expanded = self.audioTranscriptionState { + shouldExpandNow = true + } else { + if let result = transcribedText(message: message) { + shouldExpandNow = true + + if case let .success(_, isPending) = result { + shouldBeginTranscription = isPending + } else { + shouldBeginTranscription = true + } + } else { + shouldBeginTranscription = true + } + } + + if shouldBeginTranscription { + if self.transcribeDisposable == nil { + self.audioTranscriptionState = .inProgress + self.requestUpdateLayout(true) + + self.transcribeDisposable = (context.engine.messages.transcribeAudio(messageId: message.id) + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let strongSelf = self else { + return + } + strongSelf.transcribeDisposable = nil + }) + } + } + + if shouldExpandNow { + switch self.audioTranscriptionState { + case .expanded: + self.audioTranscriptionState = .collapsed + self.isWaitingForCollapse = true + self.requestUpdateLayout(true) + case .collapsed: + self.audioTranscriptionState = .inProgress + self.requestUpdateLayout(true) + default: + break + } + } + + self.updateTranscribeExpanded?(self.audioTranscriptionState, self.audioTranscriptionText) + } + + func animateTo(_ node: ChatMessageInteractiveFileNode, animator: ControlledTransitionAnimator) { + let duration: Double = 0.2 + + node.alpha = 1.0 + node.isHidden = false + + self.alpha = 0.0 + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + node.waveformView?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.1) + + if let videoNode = self.videoNode, let targetNode = node.statusNode, let videoSnapshotView = videoNode.view.snapshotView(afterScreenUpdates: false) { + videoSnapshotView.frame = videoNode.bounds + videoNode.view.insertSubview(videoSnapshotView, at: 1) + videoSnapshotView.alpha = 0.0 + videoSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, completion: { [weak videoSnapshotView] _ in + videoSnapshotView?.removeFromSuperview() + }) + + let targetFrame = targetNode.view.convert(targetNode.bounds, to: self.view) + animator.animatePosition(layer: videoNode.layer, from: videoNode.position, to: targetFrame.center, completion: { _ in + self.isHidden = true + self.customIsHidden = true + }) + let targetScale = targetNode.frame.width / videoNode.bounds.width + animator.animateScale(layer: videoNode.layer, from: self.imageScale, to: targetScale, completion: nil) + + animator.animatePosition(layer: self.infoBackgroundNode.layer, from: self.infoBackgroundNode.position, to: targetFrame.center.offsetBy(dx: 0.0, dy: 19.0), completion: nil) + animator.animateScale(layer: self.infoBackgroundNode.layer, from: 1.0, to: targetScale / self.imageScale, completion: nil) + self.infoBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + if let playbackStatusNode = self.playbackStatusNode { + animator.animatePosition(layer: playbackStatusNode.layer, from: playbackStatusNode.position, to: targetFrame.center, completion: nil) + animator.animateScale(layer: playbackStatusNode.layer, from: 1.0, to: targetScale / self.imageScale, completion: nil) + playbackStatusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + } + + let sourceFrame = self.view.convert(videoNode.frame, to: node.view) + animator.animatePosition(layer: targetNode.layer, from: sourceFrame.center, to: targetNode.position, completion: nil) + let sourceScale = (videoNode.bounds.width * self.imageScale) / targetNode.frame.width + animator.animateScale(layer: targetNode.layer, from: sourceScale, to: 1.0, completion: nil) + targetNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + + let verticalDelta = (videoNode.position.y - targetFrame.center.y) * 2.0 + animator.animatePosition(layer: node.textNode.layer, from: node.textNode.position.offsetBy(dx: 0.0, dy: verticalDelta), to: node.textNode.position, completion: nil) + node.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + } + + if let audioTranscriptionButton = self.audioTranscriptionButton, let targetAudioTranscriptionButton = node.audioTranscriptionButton { + let sourceFrame = audioTranscriptionButton.convert(audioTranscriptionButton.bounds, to: node.view) + + animator.animatePosition(layer: targetAudioTranscriptionButton.layer, from: sourceFrame.center, to: targetAudioTranscriptionButton.center, completion: nil) + targetAudioTranscriptionButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + + let targetFrame = targetAudioTranscriptionButton.convert(targetAudioTranscriptionButton.bounds, to: self.view) + animator.animatePosition(layer: audioTranscriptionButton.layer, from: audioTranscriptionButton.center, to: targetFrame.center, completion: nil) + audioTranscriptionButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + } + + let sourceDateFrame = self.dateAndStatusNode.view.convert(self.dateAndStatusNode.view.bounds, to: node.view) + let targetDateFrame = node.dateAndStatusNode.view.convert(node.dateAndStatusNode.view.bounds, to: self.view) + + animator.animatePosition(layer: self.dateAndStatusNode.layer, from: self.dateAndStatusNode.position, to: CGPoint(x: targetDateFrame.maxX - self.dateAndStatusNode.frame.width / 2.0 + 2.0, y: targetDateFrame.midY - 7.0), completion: nil) + animator.animatePosition(layer: node.dateAndStatusNode.layer, from: CGPoint(x: sourceDateFrame.maxX - node.dateAndStatusNode.frame.width / 2.0, y: sourceDateFrame.midY + 7.0), to: node.dateAndStatusNode.position, completion: nil) + + self.dateAndStatusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + node.dateAndStatusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration - 0.05, delay: 0.05) + + if let durationNode = self.durationNode, let durationBackgroundNode = self.durationBackgroundNode { + let sourceDurationFrame = durationNode.view.convert(durationNode.view.bounds, to: node.view) + let targetDurationFrame = node.fetchingTextNode.view.convert(node.fetchingTextNode.view.bounds, to: self.view) + + let delta = CGPoint(x: targetDurationFrame.center.x - durationNode.position.x, y: targetDurationFrame.center.y - durationNode.position.y) + animator.animatePosition(layer: durationNode.layer, from: durationNode.position, to: targetDurationFrame.center, completion: nil) + animator.animatePosition(layer: durationBackgroundNode.layer, from: durationBackgroundNode.position, to: durationBackgroundNode.position.offsetBy(dx: delta.x, dy: delta.y), completion: nil) + animator.animatePosition(layer: node.fetchingTextNode.layer, from: sourceDurationFrame.center, to: node.fetchingTextNode.position, completion: nil) + + durationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + self.durationBackgroundNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + node.fetchingTextNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration - 0.05, delay: 0.05) + } + } + + func animateFrom(_ node: ChatMessageInteractiveFileNode, animator: ControlledTransitionAnimator) { + let duration: Double = 0.2 + + self.alpha = 1.0 + self.isHidden = false + + node.alpha = 0.0 + node.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration, completion: { _ in + node.isHidden = true + }) + node.waveformView?.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + if let videoNode = self.videoNode, let sourceNode = node.statusNode { + videoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + + let sourceFrame = sourceNode.view.convert(sourceNode.bounds, to: self.view) + animator.animatePosition(layer: videoNode.layer, from: sourceFrame.center, to: videoNode.position, completion: nil) + let sourceScale = sourceNode.frame.width / videoNode.bounds.width + animator.animateScale(layer: videoNode.layer, from: sourceScale, to: self.imageScale, completion: nil) + + animator.animatePosition(layer: self.infoBackgroundNode.layer, from: sourceFrame.center.offsetBy(dx: 0.0, dy: 19.0), to: self.infoBackgroundNode.position, completion: nil) + animator.animateScale(layer: self.infoBackgroundNode.layer, from: sourceScale / self.imageScale, to: 1.0, completion: nil) + self.infoBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + + if let playbackStatusNode = self.playbackStatusNode { + animator.animatePosition(layer: playbackStatusNode.layer, from: sourceFrame.center, to: playbackStatusNode.position, completion: nil) + animator.animateScale(layer: playbackStatusNode.layer, from: sourceScale / self.imageScale, to: 1.0, completion: nil) + playbackStatusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + } + + let targetFrame = self.view.convert(videoNode.frame, to: node.view) + animator.animatePosition(layer: sourceNode.layer, from: sourceNode.position, to: targetFrame.center, completion: nil) + let targetScale = (videoNode.bounds.width * self.imageScale) / sourceNode.frame.width + animator.animateScale(layer: sourceNode.layer, from: 1.0, to: targetScale, completion: nil) + sourceNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + let verticalDelta = (videoNode.position.y - sourceFrame.center.y) * 2.0 + animator.animatePosition(layer: node.textNode.layer, from: node.textNode.position, to: node.textNode.position.offsetBy(dx: 0.0, dy: verticalDelta), completion: nil) + node.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + } + + if let audioTranscriptionButton = self.audioTranscriptionButton, let sourceAudioTranscriptionButton = node.audioTranscriptionButton { + audioTranscriptionButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + + let targetFrame = audioTranscriptionButton.convert(audioTranscriptionButton.bounds, to: node.view) + animator.animatePosition(layer: sourceAudioTranscriptionButton.layer, from: sourceAudioTranscriptionButton.center, to: targetFrame.center, completion: nil) + sourceAudioTranscriptionButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + let sourceFrame = sourceAudioTranscriptionButton.convert(sourceAudioTranscriptionButton.bounds, to: self.view) + animator.animatePosition(layer: audioTranscriptionButton.layer, from: sourceFrame.center, to: audioTranscriptionButton.center, completion: nil) + } + + let sourceDateFrame = node.dateAndStatusNode.view.convert(node.dateAndStatusNode.view.bounds, to: self.view) + let targetDateFrame = self.dateAndStatusNode.view.convert(self.dateAndStatusNode.view.bounds, to: node.view) + + animator.animatePosition(layer: self.dateAndStatusNode.layer, from: CGPoint(x: sourceDateFrame.maxX - self.dateAndStatusNode.frame.width / 2.0 + 2.0, y: sourceDateFrame.midY - 7.0), to: self.dateAndStatusNode.position, completion: nil) + animator.animatePosition(layer: node.dateAndStatusNode.layer, from: node.dateAndStatusNode.position, to: CGPoint(x: targetDateFrame.maxX - node.dateAndStatusNode.frame.width / 2.0, y: targetDateFrame.midY + 7.0), completion: nil) + + self.dateAndStatusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + node.dateAndStatusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + + if let durationNode = self.durationNode, let durationBackgroundNode = self.durationBackgroundNode { + let sourceDurationFrame = node.fetchingTextNode.view.convert(node.fetchingTextNode.view.bounds, to: self.view) + let targetDurationFrame = durationNode.view.convert(durationNode.view.bounds, to: node.view) + + let delta = CGPoint(x: sourceDurationFrame.center.x - durationNode.position.x, y: sourceDurationFrame.center.y - durationNode.position.y) + animator.animatePosition(layer: durationNode.layer, from: sourceDurationFrame.center, to: durationNode.position, completion: nil) + animator.animatePosition(layer: durationBackgroundNode.layer, from: durationBackgroundNode.position.offsetBy(dx: delta.x, dy: delta.y), to: durationBackgroundNode.position, completion: nil) + animator.animatePosition(layer: node.fetchingTextNode.layer, from: node.fetchingTextNode.position, to: targetDurationFrame.center, completion: nil) + + durationNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration) + self.durationBackgroundNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration - 0.05, delay: 0.05) + + node.fetchingTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration) + } + + self.customIsHidden = false + } } diff --git a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift index d50ee93948..5553ae31ef 100644 --- a/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageInteractiveMediaNode.swift @@ -76,6 +76,53 @@ struct ChatMessageDateAndStatus { var dateText: String } +public func roundedRectCgPath(roundRect rect: CGRect, topLeftRadius: CGFloat = 0.0, topRightRadius: CGFloat = 0.0, bottomLeftRadius: CGFloat = 0.0, bottomRightRadius: CGFloat = 0.0) -> CGPath { + let path = CGMutablePath() + + let topLeft = rect.origin + let topRight = CGPoint(x: rect.maxX, y: rect.minY) + let bottomRight = CGPoint(x: rect.maxX, y: rect.maxY) + let bottomLeft = CGPoint(x: rect.minX, y: rect.maxY) + + if topLeftRadius != .zero { + path.move(to: CGPoint(x: topLeft.x+topLeftRadius, y: topLeft.y)) + } else { + path.move(to: CGPoint(x: topLeft.x, y: topLeft.y)) + } + + if topRightRadius != .zero { + path.addLine(to: CGPoint(x: topRight.x-topRightRadius, y: topRight.y)) + path.addCurve(to: CGPoint(x: topRight.x, y: topRight.y+topRightRadius), control1: CGPoint(x: topRight.x, y: topRight.y), control2:CGPoint(x: topRight.x, y: topRight.y + topRightRadius)) + } else { + path.addLine(to: CGPoint(x: topRight.x, y: topRight.y)) + } + + if bottomRightRadius != .zero { + path.addLine(to: CGPoint(x: bottomRight.x, y: bottomRight.y-bottomRightRadius)) + path.addCurve(to: CGPoint(x: bottomRight.x-bottomRightRadius, y: bottomRight.y), control1: CGPoint(x: bottomRight.x, y: bottomRight.y), control2: CGPoint(x: bottomRight.x-bottomRightRadius, y: bottomRight.y)) + } else { + path.addLine(to: CGPoint(x: bottomRight.x, y: bottomRight.y)) + } + + if bottomLeftRadius != .zero { + path.addLine(to: CGPoint(x: bottomLeft.x+bottomLeftRadius, y: bottomLeft.y)) + path.addCurve(to: CGPoint(x: bottomLeft.x, y: bottomLeft.y-bottomLeftRadius), control1: CGPoint(x: bottomLeft.x, y: bottomLeft.y), control2: CGPoint(x: bottomLeft.x, y: bottomLeft.y-bottomLeftRadius)) + } else { + path.addLine(to: CGPoint(x: bottomLeft.x, y: bottomLeft.y)) + } + + if topLeftRadius != .zero { + path.addLine(to: CGPoint(x: topLeft.x, y: topLeft.y+topLeftRadius)) + path.addCurve(to: CGPoint(x: topLeft.x+topLeftRadius, y: topLeft.y) , control1: CGPoint(x: topLeft.x, y: topLeft.y) , control2: CGPoint(x: topLeft.x+topLeftRadius, y: topLeft.y)) + } else { + path.addLine(to: CGPoint(x: topLeft.x, y: topLeft.y)) + } + + path.closeSubpath() + + return path +} + extension UIBezierPath { convenience init(roundRect rect: CGRect, topLeftRadius: CGFloat = 0.0, topRightRadius: CGFloat = 0.0, bottomLeftRadius: CGFloat = 0.0, bottomRightRadius: CGFloat = 0.0) { self.init() diff --git a/submodules/TelegramUI/Sources/ChatMessageItem.swift b/submodules/TelegramUI/Sources/ChatMessageItem.swift index 496a121ff3..523abcfb5f 100644 --- a/submodules/TelegramUI/Sources/ChatMessageItem.swift +++ b/submodules/TelegramUI/Sources/ChatMessageItem.swift @@ -431,7 +431,8 @@ public final class ChatMessageItem: ListViewItem, CustomStringConvertible { break loop case let .Video(_, _, flags): if flags.contains(.instantRoundVideo) { - viewClassName = ChatMessageInstantVideoItemNode.self +// viewClassName = ChatMessageInstantVideoItemNode.self + viewClassName = ChatMessageBubbleItemNode.self break loop } default: diff --git a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift index fdfb28040f..1a81c6ae52 100644 --- a/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift +++ b/submodules/TelegramUI/Sources/ChatRecentActionsControllerNode.swift @@ -543,6 +543,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { }, requestMessageUpdate: { _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { + }, scrollToMessageId: { _ in }, automaticMediaDownloadSettings: self.automaticMediaDownloadSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(loopAnimatedStickers: false), presentationContext: ChatPresentationContext(context: context, backgroundNode: self.backgroundNode)) self.controllerInteraction = controllerInteraction diff --git a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift index 3f9822453d..01032a01c9 100644 --- a/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift +++ b/submodules/TelegramUI/Sources/ChatSearchResultsContollerNode.swift @@ -237,6 +237,7 @@ class ChatSearchResultsControllerNode: ViewControllerTracingNode, UIScrollViewDe }, updatePeerGrouping: { _, _ in }, togglePeerMarkedUnread: { _, _ in }, toggleArchivedFolderHiddenByDefault: { + }, toggleThreadsSelection: { _, _ in }, hidePsa: { _ in }, activateChatPreview: { [weak self] item, node, gesture, _ in guard let strongSelf = self else { diff --git a/submodules/TelegramUI/Sources/DrawingStickersScreen.swift b/submodules/TelegramUI/Sources/DrawingStickersScreen.swift index 50fe2b34cf..5b9f15ef28 100644 --- a/submodules/TelegramUI/Sources/DrawingStickersScreen.swift +++ b/submodules/TelegramUI/Sources/DrawingStickersScreen.swift @@ -164,6 +164,7 @@ private final class DrawingStickersScreenNode: ViewControllerTracingNode { }, requestMessageUpdate: { _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { + }, scrollToMessageId: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(loopAnimatedStickers: true), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) diff --git a/submodules/TelegramUI/Sources/NavigateToChatController.swift b/submodules/TelegramUI/Sources/NavigateToChatController.swift index 9c6aff4488..25575e1a2c 100644 --- a/submodules/TelegramUI/Sources/NavigateToChatController.swift +++ b/submodules/TelegramUI/Sources/NavigateToChatController.swift @@ -235,21 +235,22 @@ public func navigateToForumThreadImpl(context: AccountContext, peerId: EnginePee return } - let chatLocation: ChatLocation = .replyThread(message: result.message) - - let subject: ChatControllerSubject? - if let messageId = messageId { - subject = .message(id: .id(messageId), highlight: true, timecode: nil) - } else { - subject = nil - } - var actualActivateInput: ChatControllerActivateInput? = result.isEmpty ? .text : nil if let activateInput = activateInput { actualActivateInput = activateInput } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: chatLocation, chatLocationContextHolder: result.contextHolder, subject: subject, activateInput: actualActivateInput, keepStack: .never)) + context.sharedContext.navigateToChatController( + NavigateToChatControllerParams( + navigationController: navigationController, + context: context, + chatLocation: .replyThread(message: result.message), + chatLocationContextHolder: result.contextHolder, + subject: messageId.flatMap { .message(id: .id($0), highlight: true, timecode: nil) }, + activateInput: actualActivateInput, + keepStack: .never + ) + ) } |> ignoreValues |> `catch` { _ -> Signal in @@ -257,6 +258,21 @@ public func navigateToForumThreadImpl(context: AccountContext, peerId: EnginePee } } +public func chatControllerForForumThreadImpl(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64) -> Signal { + return fetchAndPreloadReplyThreadInfo(context: context, subject: .groupMessage(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: Int32(clamping: threadId))), atMessageId: nil, preload: false) + |> deliverOnMainQueue + |> `catch` { _ -> Signal in + return .complete() + } + |> map { result in + return ChatControllerImpl( + context: context, + chatLocation: .replyThread(message: result.message), + chatLocationContextHolder: result.contextHolder + ) + } +} + public func navigateToForumChannelImpl(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) { let controller = ChatListControllerImpl(context: context, location: .forum(peerId: peerId), controlsHistoryPreload: false, enableDebugActions: false) controller.navigationPresentation = .master diff --git a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift index dd6a078974..5531b41ea1 100644 --- a/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift +++ b/submodules/TelegramUI/Sources/OverlayAudioPlayerControllerNode.swift @@ -157,6 +157,7 @@ final class OverlayAudioPlayerControllerNode: ViewControllerTracingNode, UIGestu }, requestMessageUpdate: { _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { + }, scrollToMessageId: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(loopAnimatedStickers: false), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.dimNode = ASDisplayNode() diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift index 1280220f1e..77c35beee9 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoHeaderNode.swift @@ -648,6 +648,13 @@ final class PeerInfoEditingAvatarOverlayNode: ASDisplayNode { let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .linear) + let clipStyle: AvatarNodeClipStyle + if let channel = peer as? TelegramChannel, channel.flags.contains(.isForum) { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + if canEditPeerInfo(context: self.context, peer: peer, threadData: threadData) { var overlayHidden = true if let updatingAvatar = updatingAvatar { @@ -658,7 +665,8 @@ final class PeerInfoEditingAvatarOverlayNode: ASDisplayNode { if case let .image(representation) = updatingAvatar { if representation != self.currentRepresentation { self.currentRepresentation = representation - if let signal = peerAvatarImage(account: context.account, peerReference: nil, authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: avatarSize, height: avatarSize), emptyColor: nil, synchronousLoad: false, provideUnrounded: false) { + + if let signal = peerAvatarImage(account: context.account, peerReference: nil, authorOfMessage: nil, representation: representation, displayDimensions: CGSize(width: avatarSize, height: avatarSize), clipStyle: clipStyle, emptyColor: nil, synchronousLoad: false, provideUnrounded: false) { self.imageNode.setSignal(signal |> map { $0?.0 }) } } @@ -680,7 +688,14 @@ final class PeerInfoEditingAvatarOverlayNode: ASDisplayNode { } } if !overlayHidden && self.updatingAvatarOverlay.image == nil { - self.updatingAvatarOverlay.image = generateFilledCircleImage(diameter: avatarSize, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil) + switch clipStyle { + case .round: + self.updatingAvatarOverlay.image = generateFilledCircleImage(diameter: avatarSize, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil) + case .roundedRect: + self.updatingAvatarOverlay.image = generateFilledRoundedRectImage(size: CGSize(width: avatarSize, height: avatarSize), cornerRadius: avatarSize * 0.25, color: UIColor(white: 0.0, alpha: 0.4), backgroundColor: nil) + default: + break + } } } else { self.statusNode.transitionToState(.none) diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index f48030321e..006fc13be3 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -2541,6 +2541,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate }, requestMessageUpdate: { _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { + }, scrollToMessageId: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(loopAnimatedStickers: false), presentationContext: ChatPresentationContext(context: context, backgroundNode: nil)) self.hiddenMediaDisposable = context.sharedContext.mediaManager.galleryHiddenMediaManager.hiddenIds().start(next: { [weak self] ids in @@ -7297,7 +7298,7 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: savedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) } - peerSelectionController.peerSelected = { [weak self, weak peerSelectionController] peer, _ in + peerSelectionController.peerSelected = { [weak self, weak peerSelectionController] peer, threadId in let peerId = peer.id if let strongSelf = self, let _ = peerSelectionController { @@ -7334,27 +7335,40 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate peerSelectionController.dismiss() } } else { - let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: nil, { currentState in + let _ = (ChatInterfaceState.update(engine: strongSelf.context.engine, peerId: peerId, threadId: threadId, { currentState in return currentState.withUpdatedForwardMessageIds(Array(messageIds)) }) |> deliverOnMainQueue).start(completed: { if let strongSelf = self { - strongSelf.headerNode.navigationButtonContainer.performAction?(.selectionDone, nil, nil) - - if let navigationController = strongSelf.controller?.navigationController as? NavigationController { - let chatController = ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId)) - var viewControllers = navigationController.viewControllers - viewControllers.insert(chatController, at: viewControllers.count - 1) - navigationController.setViewControllers(viewControllers, animated: false) + let proceed: (ChatController) -> Void = { chatController in + strongSelf.headerNode.navigationButtonContainer.performAction?(.selectionDone, nil, nil) - strongSelf.activeActionDisposable.set((chatController.ready.get() - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(next: { _ in - if let peerSelectionController = peerSelectionController { - peerSelectionController.dismiss() + if let navigationController = strongSelf.controller?.navigationController as? NavigationController { + var viewControllers = navigationController.viewControllers + if threadId != nil { + viewControllers.insert(chatController, at: viewControllers.count - 2) + } else { + viewControllers.insert(chatController, at: viewControllers.count - 1) } - })) + navigationController.setViewControllers(viewControllers, animated: false) + + strongSelf.activeActionDisposable.set((chatController.ready.get() + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak navigationController] _ in + viewControllers.removeAll(where: { $0 is PeerSelectionController }) + navigationController?.setViewControllers(viewControllers, animated: true) + })) + } + } + + if let threadId = threadId { + let _ = (strongSelf.context.sharedContext.chatControllerForForumThread(context: strongSelf.context, peerId: peerId, threadId: threadId) + |> deliverOnMainQueue).start(next: { chatController in + proceed(chatController) + }) + } else { + proceed(ChatControllerImpl(context: strongSelf.context, chatLocation: .peer(id: peerId))) } } }) diff --git a/submodules/TelegramUI/Sources/PeerSelectionController.swift b/submodules/TelegramUI/Sources/PeerSelectionController.swift index 4dc6a72298..9a2f0c710e 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionController.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionController.swift @@ -94,14 +94,16 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon super.init(navigationBarPresentationData: NavigationBarPresentationData(presentationData: self.presentationData)) - self.navigationPresentation = .modal - self.statusBar.statusBarStyle = self.presentationData.theme.rootController.statusBarStyle.style self.customTitle = params.title self.title = self.customTitle ?? self.presentationData.strings.Conversation_ForwardTitle - self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + if params.forumPeerId == nil { + self.navigationPresentation = .modal + self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Cancel, style: .plain, target: self, action: #selector(self.cancelPressed)) + } + self.navigationItem.backBarButtonItem = UIBarButtonItem(title: self.presentationData.strings.Common_Back, style: .plain, target: nil, action: nil) self.scrollToTop = { [weak self] in if let strongSelf = self { @@ -179,7 +181,29 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon self.peerSelectionNode.requestOpenPeer = { [weak self] peer, threadId in if let strongSelf = self, let peerSelected = strongSelf.peerSelected { - peerSelected(peer, threadId) + if let peer = peer as? TelegramChannel, peer.flags.contains(.isForum), threadId == nil { + let controller = PeerSelectionControllerImpl( + PeerSelectionControllerParams( + context: strongSelf.context, + updatedPresentationData: nil, + filter: strongSelf.filter, + forumPeerId: peer.id, + hasChatListSelector: false, + hasContactSelector: false, + hasGlobalSearch: false, + title: EnginePeer(peer).compactDisplayTitle, + attemptSelection: strongSelf.attemptSelection, + createNewGroup: nil, + pretendPresentedInModal: false, + multipleSelection: false, + forwardedMessageIds: [], + hasTypeHeaders: false) + ) + controller.peerSelected = strongSelf.peerSelected + strongSelf.push(controller) + } else { + peerSelected(peer, threadId) + } } } diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 5e676c4756..286b1f878f 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -1163,6 +1163,10 @@ public final class SharedAccountContextImpl: SharedAccountContext { return navigateToForumThreadImpl(context: context, peerId: peerId, threadId: threadId, messageId: messageId, navigationController: navigationController, activateInput: activateInput) } + public func chatControllerForForumThread(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64) -> Signal { + return chatControllerForForumThreadImpl(context: context, peerId: peerId, threadId: threadId) + } + public func openStorageUsage(context: AccountContext) { guard let navigationController = self.mainWindow?.viewController as? NavigationController else { return @@ -1347,6 +1351,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { }, requestMessageUpdate: { _ in }, cancelInteractiveKeyboardGestures: { }, dismissTextInput: { + }, scrollToMessageId: { _ in }, automaticMediaDownloadSettings: MediaAutoDownloadSettings.defaultSettings, pollActionState: ChatInterfacePollActionState(), stickerSettings: ChatInterfaceStickerSettings(loopAnimatedStickers: false), presentationContext: ChatPresentationContext(context: context, backgroundNode: backgroundNode as? WallpaperBackgroundNode)) diff --git a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift index 370eba7436..60b0490bf2 100644 --- a/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift +++ b/submodules/UndoUI/Sources/UndoOverlayControllerNode.swift @@ -1115,7 +1115,7 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { var insets = layout.insets(options: [.input]) switch self.placementPosition { case .top: - insets.top = layout.statusBarHeight ?? 0.0 + break case .bottom: if self.elevatedLayout { insets.bottom += 49.0 @@ -1126,8 +1126,8 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { var panelWrapperFrame = CGRect(origin: CGPoint(x: margin + layout.safeInsets.left, y: layout.size.height - contentHeight - insets.bottom - margin), size: CGSize(width: layout.size.width - margin * 2.0 - layout.safeInsets.left - layout.safeInsets.right, height: contentHeight)) if case .top = self.placementPosition { - panelFrame.origin.y = insets.top - panelWrapperFrame.origin.y = insets.top + panelFrame.origin.y = insets.top + margin + panelWrapperFrame.origin.y = insets.top + margin } transition.updateFrame(node: self.panelNode, frame: panelFrame) @@ -1232,7 +1232,14 @@ final class UndoOverlayControllerNode: ViewControllerTracingNode { func animateIn(asReplacement: Bool) { if asReplacement { - let offset = self.bounds.height - self.panelWrapperNode.frame.minY + let offset: CGFloat + switch self.placementPosition { + case .top: + offset = -self.panelWrapperNode.frame.maxY + case.bottom: + offset = self.bounds.height - self.panelWrapperNode.frame.minY + } + self.panelWrapperNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.35, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: nil) self.panelNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.35, delay: 0.0, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, additive: true, completion: nil) } else { diff --git a/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift b/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift index a671119aa5..9f7fcc78ab 100644 --- a/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift +++ b/submodules/WallpaperBackgroundNode/Sources/WallpaperBackgroundNode.swift @@ -1115,7 +1115,7 @@ final class WallpaperBackgroundNodeImpl: ASDisplayNode, WallpaperBackgroundNode self.gradientBackgroundNode?.animateEvent(transition: transition, extendAnimation: extendAnimation, backwards: false, completion: { [weak self] in if let strongSelf = self { strongSelf.isAnimating = false - if strongSelf.isLooping { + if strongSelf.isLooping && strongSelf.validLayout != nil { strongSelf.animateEvent(transition: transition, extendAnimation: extendAnimation) } }