diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 12c0d44b7e..07f4df206b 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -14013,9 +14013,17 @@ Sorry for the inconvenience."; "Notification.PaidMessagePriceChanged.Stars_1" = "%@ Star"; "Notification.PaidMessagePriceChanged.Stars_any" = "%@ Stars"; "Notification.PaidMessagePriceChanged" = "%1$@ changed price to %2$@ per message"; -"Notification.PaidMessagePriceChangedAndEnabledChannelMessage" = "%1$@ changed price to %2$@ per message, and enabled messaging this channel"; "Notification.PaidMessagePriceChangedYou" = "You changed price to %1$@ per message"; -"Notification.PaidMessagePriceChangedAndEnabledChannelMessageYou" = "You changed price to %1$@ per message, and enabled messaging this channel"; + +"Notification.ChannelMessagePriceChanged_1" = "{name} set the private message price to %d star"; +"Notification.ChannelMessagePriceChanged_any" = "{name} set the private message price to %d stars"; +"Notification.ChannelMessagePriceZeroChanged" = "%@ now accepts private messages"; + +"Notification.ChannelMessagePriceChangedAndEnabledChannelMessage_1" = "{name} now accepts private messages for %d star"; +"Notification.ChannelMessagePriceChangedAndEnabledChannelMessage_any" = "{name} now accepts private messages for %d stars"; +"Notification.ChannelMessagePriceZeroChangedAndEnabledChannelMessage" = "%@ now accepts private messages"; + +"Notification.ChannelMessageDisabled" = "%@ no longer accepts private messages"; "Premium.MaxExpiringStoriesTextNumberFormat_1" = "**%d** story"; "Premium.MaxExpiringStoriesTextNumberFormat_any" = "**%d** stories"; @@ -14368,3 +14376,9 @@ Sorry for the inconvenience."; "PeerInfo.OptionTopics.Enabled" = "Enabled"; "PeerInfo.OptionTopics.Disabled" = "Disabled"; + +"ChannelMessages.Title" = "Allow Channel Messages"; +"ChannelMessages.Info" = "Allow users to send messages to your channel, with the option to charge a fee for each message."; +"ChannelMessages.SwitchTitle" = "Allow Channel Messages"; +"ChannelMessages.PriceSectionTitle" = "PRICE FOR EACH MESSAGE"; +"ChannelMessages.PriceSectionFooter" = "Charge users for the ability to suggest one post for your channel. You're not required to publish any suggestions by charging this. You'll receive 85% of the selected fee for each incoming suggestion."; diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 6ba78eb0f2..b4c716e0f0 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -1118,6 +1118,7 @@ public protocol SharedAccountContext: AnyObject { func makeCollectibleItemInfoScreen(context: AccountContext, initialData: CollectibleItemInfoScreenInitialData) -> ViewController func makeCollectibleItemInfoScreenInitialData(context: AccountContext, peerId: EnginePeer.Id, subject: CollectibleItemInfoScreenSubject) -> Signal func makeBotSettingsScreen(context: AccountContext, peerId: EnginePeer.Id?) -> ViewController + func makeEditForumTopicScreen(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, threadInfo: EngineMessageHistoryThread.Info, isHidden: Bool) -> ViewController func navigateToChatController(_ params: NavigateToChatControllerParams) func navigateToForumChannel(context: AccountContext, peerId: EnginePeer.Id, navigationController: NavigationController) diff --git a/submodules/AccountContext/Sources/PeerSelectionController.swift b/submodules/AccountContext/Sources/PeerSelectionController.swift index aef3c6d9ff..c08d6edcdb 100644 --- a/submodules/AccountContext/Sources/PeerSelectionController.swift +++ b/submodules/AccountContext/Sources/PeerSelectionController.swift @@ -50,7 +50,7 @@ public final class PeerSelectionControllerParams { public let updatedPresentationData: (initial: PresentationData, signal: Signal)? public let filter: ChatListNodePeersFilter public let requestPeerType: [ReplyMarkupButtonRequestPeerType]? - public let forumPeerId: EnginePeer.Id? + public let forumPeerId: (id: EnginePeer.Id, isMonoforum: Bool)? public let hasFilters: Bool public let hasChatListSelector: Bool public let hasContactSelector: Bool @@ -72,7 +72,7 @@ public final class PeerSelectionControllerParams { updatedPresentationData: (initial: PresentationData, signal: Signal)? = nil, filter: ChatListNodePeersFilter = [.onlyWriteable], requestPeerType: [ReplyMarkupButtonRequestPeerType]? = nil, - forumPeerId: EnginePeer.Id? = nil, + forumPeerId: (id: EnginePeer.Id, isMonoforum: Bool)? = nil, hasFilters: Bool = false, hasChatListSelector: Bool = true, hasContactSelector: Bool = true, diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 6f53f3e534..89350d09b0 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -1297,7 +1297,11 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { if let data = view.cachedData as? CachedUserData { return data.sendPaidMessageStars } else if let channel = peerViewMainPeer(view) as? TelegramChannel { - return channel.sendPaidMessageStars + if channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = view.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + return nil + } else { + return channel.sendPaidMessageStars + } } else { return nil } diff --git a/submodules/AvatarNode/Sources/AvatarNode.swift b/submodules/AvatarNode/Sources/AvatarNode.swift index c72505e5a1..31113827f4 100644 --- a/submodules/AvatarNode/Sources/AvatarNode.swift +++ b/submodules/AvatarNode/Sources/AvatarNode.swift @@ -35,6 +35,7 @@ public enum AvatarNodeClipStyle { case none case round case roundedRect + case bubble } private class AvatarNodeParameters: NSObject { @@ -272,6 +273,30 @@ public final class AvatarEditOverlayNode: ASDisplayNode { } public final class AvatarNode: ASDisplayNode { + public static func avatarBubbleMask(size: CGSize) -> UIImage! { + return generateImage(size, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(UIColor.white.cgColor) + AvatarNode.addAvatarBubblePath(context: context, rect: CGRect(origin: CGPoint(), size: size)) + context.fillPath() + }) + } + + public static func addAvatarBubblePath(context: CGContext, rect: CGRect) { + if let path = try? convertSvgPath("M60,30.274903 C60,46.843446 46.568544,60.274904 30,60.274904 C13.431458,60.274904 0,46.843446 0,30.274903 C0,23.634797 2.158635,17.499547 5.810547,12.529785 L6.036133,12.226074 C6.921364,10.896042 7.367402,8.104698 5.548828,5.316895 C3.606939,2.340088 1.186019,0.979668 2.399414,0.470215 C3.148032,0.156204 7.572027,0.000065 10.764648,1.790527 C12.148517,2.56662 13.2296,3.342422 14.09224,4.039734 C14.42622,4.309704 14.892063,4.349773 15.265962,4.138523 C19.618079,1.679604 24.644722,0.274902 30,0.274902 C46.568544,0.274902 60,13.70636 60,30.274903 Z ") { + let sx = rect.width / 60.0 + let sy = rect.height / 60.0 + var transform = CGAffineTransform( + a: sx, b: 0.0, + c: 0.0, d: -sy, + tx: rect.minX, + ty: rect.minY + rect.height + ) + let transformedPath = path.copy(using: &transform)! + context.addPath(transformedPath) + } + } + public static let gradientColors: [[UIColor]] = [ [UIColor(rgb: 0xff516a), UIColor(rgb: 0xff885e)], [UIColor(rgb: 0xffa85c), UIColor(rgb: 0xffcd6a)], @@ -335,6 +360,7 @@ public final class AvatarNode: ASDisplayNode { private var theme: PresentationTheme? private var overrideImage: AvatarNodeImageOverride? public let imageNode: ImageNode + private var imageNodeMask: UIImageView? public var editOverlayNode: AvatarEditOverlayNode? private let imageReadyDisposable = MetaDisposable() @@ -399,7 +425,7 @@ public final class AvatarNode: ASDisplayNode { self.displaysAsynchronously = true self.disableClearContentsOnHide = true - self.imageNode.isLayerBacked = true + self.imageNode.isUserInteractionEnabled = false self.addSubnode(self.imageNode) self.imageNode.contentUpdated = { [weak self] image in @@ -434,6 +460,9 @@ public final class AvatarNode: ASDisplayNode { public func updateSize(size: CGSize) { self.imageNode.frame = CGRect(origin: CGPoint(), size: size) self.editOverlayNode?.frame = self.imageNode.frame + if let imageNodeMask = self.imageNodeMask { + imageNodeMask.frame = CGRect(origin: CGPoint(), size: size) + } if !self.displaySuspended { self.setNeedsDisplay() self.editOverlayNode?.setNeedsDisplay() @@ -678,6 +707,7 @@ public final class AvatarNode: ASDisplayNode { if self.params == params { return } + let previousSize = self.params?.displayDimensions self.params = params switch clipStyle { @@ -690,6 +720,29 @@ public final class AvatarNode: ASDisplayNode { case .roundedRect: self.imageNode.clipsToBounds = true self.imageNode.cornerRadius = displayDimensions.height * 0.25 + case .bubble: + break + } + + if case .bubble = clipStyle { + var updateMask = false + let imageNodeMask: UIImageView + if let current = self.imageNodeMask { + imageNodeMask = current + updateMask = previousSize != params.displayDimensions + } else { + imageNodeMask = UIImageView() + self.imageNodeMask = imageNodeMask + self.imageNode.view.mask = imageNodeMask + imageNodeMask.frame = self.imageNode.frame + updateMask = true + } + if updateMask { + imageNodeMask.image = AvatarNode.avatarBubbleMask(size: params.displayDimensions) + } + } else if self.imageNodeMask != nil { + self.imageNodeMask = nil + self.imageNode.view.mask = nil } if let imageCache = genericContext.imageCache as? DirectMediaImageCache, let peer, let smallProfileImage = peer.smallProfileImage, let peerReference = PeerReference(peer._asPeer()) { @@ -902,6 +955,10 @@ public final class AvatarNode: ASDisplayNode { context.beginPath() context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height), cornerRadius: floor(bounds.size.width * 0.25)).cgPath) context.clip() + } else if case .bubble = parameters.clipStyle { + context.beginPath() + AvatarNode.addAvatarBubblePath(context: context, rect: CGRect(x: 0.0, y: 0.0, width: bounds.size.width, height: bounds.size.height)) + context.clip() } } else { colors = grayscaleColors @@ -1472,3 +1529,4 @@ public final class AvatarNode: ASDisplayNode { } } } + diff --git a/submodules/AvatarNode/Sources/PeerAvatar.swift b/submodules/AvatarNode/Sources/PeerAvatar.swift index d6ac7578ed..e4c3bf1fc4 100644 --- a/submodules/AvatarNode/Sources/PeerAvatar.swift +++ b/submodules/AvatarNode/Sources/PeerAvatar.swift @@ -214,6 +214,16 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P case .roundedRect: context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: displayDimensions.width, height: displayDimensions.height).insetBy(dx: inset, dy: inset), cornerRadius: floor(displayDimensions.width * 0.25)).cgPath) context.clip() + case .bubble: + let rect = CGRect(origin: CGPoint(), size: displayDimensions).insetBy(dx: inset, dy: inset) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + AvatarNode.addAvatarBubblePath(context: context, rect: rect) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + context.clip() } var shouldBlur = false @@ -265,6 +275,8 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P } case .roundedRect: break + case .bubble: + break } } else { if let emptyColor = emptyColor { @@ -279,6 +291,16 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P context.beginPath() context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: displayDimensions.width, height: displayDimensions.height).insetBy(dx: inset, dy: inset), cornerRadius: floor(displayDimensions.width * 0.25)).cgPath) context.fillPath() + case .bubble: + let rect = CGRect(origin: CGPoint(), size: displayDimensions).insetBy(dx: inset, dy: inset) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + AvatarNode.addAvatarBubblePath(context: context, rect: rect) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + context.clip() } } } @@ -295,6 +317,16 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P context.beginPath() context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: displayDimensions.width, height: displayDimensions.height).insetBy(dx: inset, dy: inset), cornerRadius: floor(displayDimensions.width * 0.25)).cgPath) context.fillPath() + case .bubble: + let rect = CGRect(origin: CGPoint(), size: displayDimensions).insetBy(dx: inset, dy: inset) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + AvatarNode.addAvatarBubblePath(context: context, rect: rect) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + context.clip() } } @@ -332,6 +364,16 @@ public func peerAvatarImage(postbox: Postbox, network: Network, peerReference: P context.beginPath() context.addPath(UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: displayDimensions.width, height: displayDimensions.height).insetBy(dx: inset, dy: inset), cornerRadius: floor(displayDimensions.width * 0.25)).cgPath) context.fillPath() + case .bubble: + let rect = CGRect(origin: CGPoint(), size: displayDimensions).insetBy(dx: inset, dy: inset) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + AvatarNode.addAvatarBubblePath(context: context, rect: rect) + context.translateBy(x: rect.midX, y: rect.midY) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -rect.midX, y: -rect.midY) + context.clip() } } }) diff --git a/submodules/ChatListUI/Sources/ChatContextMenus.swift b/submodules/ChatListUI/Sources/ChatContextMenus.swift index 2dfab03f46..e9a3e09383 100644 --- a/submodules/ChatListUI/Sources/ChatContextMenus.swift +++ b/submodules/ChatListUI/Sources/ChatContextMenus.swift @@ -581,7 +581,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch } } -func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: Int64, isPinned: Bool?, isClosed: Bool?, chatListController: ChatListControllerImpl?, joined: Bool, canSelect: Bool) -> Signal<[ContextMenuItem], NoError> { +public func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: Int64, isPinned: Bool?, isClosed: Bool?, chatListController: ViewController?, joined: Bool, canSelect: Bool, customEdit: ((ContextController) -> Void)? = nil, customPinUnpin: ((ContextController) -> Void)? = nil, reorder: (() -> Void)? = nil) -> Signal<[ContextMenuItem], NoError> { let presentationData = context.sharedContext.currentPresentationData.with({ $0 }) let strings = presentationData.strings @@ -603,8 +603,17 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: if let isClosed = isClosed, isClosed && threadId != 1 { } else { - if let isPinned = isPinned, channel.hasPermission(.manageTopics) { - items.append(.action(ContextMenuActionItem(text: isPinned ? presentationData.strings.ChatList_Context_Unpin : presentationData.strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { _, f in + if let isPinned, channel.hasPermission(.manageTopics) { + items.append(.action(ContextMenuActionItem(text: isPinned ? presentationData.strings.ChatList_Context_Unpin : presentationData.strings.ChatList_Context_Pin, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: isPinned ? "Chat/Context Menu/Unpin": "Chat/Context Menu/Pin"), color: theme.contextMenu.primaryColor) }, action: { c, f in + if let customPinUnpin { + if let c = c as? ContextController { + customPinUnpin(c) + } else { + f(.default) + } + return + } + f(.default) let _ = (context.engine.peers.toggleForumChannelTopicPinned(id: peerId, threadId: threadId) @@ -621,6 +630,15 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: } }) }))) + + if isPinned, let reorder { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Reorder", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReorderItems"), color: theme.contextMenu.primaryColor) }, action: { c, _ in + c?.dismiss(completion: { + }) + reorder() + }))) + } } } @@ -636,6 +654,27 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: }))) } + var canOpenClose = false + if channel.flags.contains(.isCreator) { + canOpenClose = true + } else if channel.hasPermission(.manageTopics) { + canOpenClose = true + } else if threadData.isOwnedByMe { + canOpenClose = true + } + + if threadId != 1, canOpenClose, let customEdit { + //TODO:localize + items.append(.action(ContextMenuActionItem(text: "Edit", icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Edit"), color: theme.contextMenu.primaryColor) }, action: { c, f in + if let c = c as? ContextController { + customEdit(c) + } else { + f(.default) + } + return + }))) + } + var isMuted = false switch threadData.notificationSettings.muteState { case .muted: @@ -863,14 +902,6 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: }))) if threadId != 1 { - var canOpenClose = false - if channel.flags.contains(.isCreator) { - canOpenClose = true - } else if channel.hasPermission(.manageTopics) { - canOpenClose = true - } else if threadData.isOwnedByMe { - canOpenClose = true - } if canOpenClose { items.append(.action(ContextMenuActionItem(text: threadData.isClosed ? presentationData.strings.ChatList_Context_ReopenTopic : presentationData.strings.ChatList_Context_CloseTopic, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: threadData.isClosed ? "Chat/Context Menu/Play": "Chat/Context Menu/Pause"), color: theme.contextMenu.primaryColor) }, action: { _, f in f(.default) @@ -882,14 +913,36 @@ func chatForumTopicMenuItems(context: AccountContext, peerId: PeerId, threadId: items.append(.action(ContextMenuActionItem(text: strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak chatListController] _, f in f(.default) - chatListController?.deletePeerThread(peerId: peerId, threadId: threadId) + if let chatListController = chatListController as? ChatListControllerImpl { + chatListController.deletePeerThread(peerId: peerId, threadId: threadId) + } else if let chatListController { + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + items.append(ActionSheetTextItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationText, parseMarkdown: true)) + items.append(ActionSheetButtonItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationAction, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + let _ = context.engine.peers.removeForumChannelThread(id: peerId, threadId: threadId).startStandalone(completed: { + }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + chatListController.present(actionSheet, in: .window(.root)) + } }))) } } - if canSelect { + if canSelect, let chatListController = chatListController as? ChatListControllerImpl { 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 + 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: { [weak chatListController] _, f in f(.default) chatListController?.selectPeerThread(peerId: peerId, threadId: threadId) }))) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 4f5a1f5393..38a13358b1 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -1318,6 +1318,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { private var inlineNavigationMarkLayer: SimpleLayer? public let titleNode: TextNode + private var titleBadge: (backgroundView: UIImageView, textNode: TextNode)? public let authorNode: AuthorNode private var compoundHighlightingNode: LinkHighlightingNode? private var textArrowNode: ASImageNode? @@ -1835,10 +1836,19 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { self.avatarNode.font = avatarPlaceholderFont(size: avatarFontSize) } } - if peer.smallProfileImage != nil && overrideImage == nil { - self.avatarNode.setPeerV2(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: isForumAvatar ? .roundedRect : .round, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: avatarDiameter, height: avatarDiameter)) + let avatarClipStyle: AvatarNodeClipStyle + if peerIsMonoforum { + avatarClipStyle = .bubble + } else if isForumAvatar { + avatarClipStyle = .roundedRect } else { - self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: isForumAvatar ? .roundedRect : .round, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: 60.0, height: 60.0)) + avatarClipStyle = .round + } + + if peer.smallProfileImage != nil && overrideImage == nil { + self.avatarNode.setPeerV2(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: avatarClipStyle, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: avatarDiameter, height: avatarDiameter)) + } else { + self.avatarNode.setPeer(context: item.context, theme: item.presentationData.theme, peer: peer, overrideImage: overrideImage, emptyColor: item.presentationData.theme.list.mediaPlaceholderColor, clipStyle: avatarClipStyle, synchronousLoad: synchronousLoads, displayDimensions: CGSize(width: 60.0, height: 60.0)) } if peer.isPremium && peer.id != item.context.account.peerId { @@ -2028,6 +2038,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let textLayout = TextNodeWithEntities.asyncLayout(self.textNode) let makeTrailingTextBadgeLayout = TextNode.asyncLayout(self.trailingTextBadgeNode) let titleLayout = TextNode.asyncLayout(self.titleNode) + let titleBadgeLayout = TextNode.asyncLayout(self.titleBadge?.textNode) let authorLayout = self.authorNode.asyncLayout() let makeMeasureLayout = TextNode.asyncLayout(self.measureNode) let inputActivitiesLayout = self.inputActivitiesNode.asyncLayout() @@ -2226,6 +2237,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { var textLeftCutout: CGFloat = 0.0 var dateAttributedString: NSAttributedString? var titleAttributedString: NSAttributedString? + var titleBadgeText: String? var badgeContent = ChatListBadgeContent.none var mentionBadgeContent = ChatListBadgeContent.none var statusState = ChatListStatusNodeState.none @@ -3001,12 +3013,11 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else { textColor = theme.titleColor } - //TODO:localize if case let .channel(channel) = itemPeer.peer, channel.flags.contains(.isMonoforum) { - titleAttributedString = NSAttributedString(string: "\(displayTitle) Messages", font: titleFont, textColor: textColor) - } else { - titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: textColor) + //TODO:localize + titleBadgeText = "MESSAGES" } + titleAttributedString = NSAttributedString(string: displayTitle, font: titleFont, textColor: textColor) } case .group: titleAttributedString = NSAttributedString(string: item.presentationData.strings.ChatList_ArchivedChatsTitle, font: titleFont, textColor: theme.titleColor) @@ -3224,7 +3235,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { default: break } - } else if case let .chat(itemPeer) = contentPeer, let peer = itemPeer.chatMainPeer { + } else if case let .chat(itemPeer) = contentPeer, let peer = itemPeer.chatOrMonoforumMainPeer { if peer.isSubscription { isSubscription = true } @@ -3369,7 +3380,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { } else if case let .peer(peer) = item.content, case let .channel(channel) = peer.peer.peer, channel.flags.contains(.isMonoforum) { if forumThread != nil || !topForumTopicItems.isEmpty { if let forumThread { - isFirstForumThreadSelectable = forumThread.isUnread + isFirstForumThreadSelectable = false forumThreads.append((id: forumThread.id, threadPeer: forumThread.threadPeer, title: NSAttributedString(string: forumThread.threadPeer?.compactDisplayTitle ?? " ", font: textFont, textColor: forumThread.isUnread || isSearching ? theme.authorNameColor : theme.messageTextColor), iconId: nil, iconColor: nil)) } for topicItem in topForumTopicItems { @@ -3463,11 +3474,19 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { titleAttributedString = NSAttributedString(string: " ", font: titleFont, textColor: theme.titleColor) } - let titleRectWidth = rawContentWidth - dateLayout.size.width - 10.0 - statusWidth - titleIconsWidth + var titleRectWidth = rawContentWidth - dateLayout.size.width - 10.0 - statusWidth - titleIconsWidth var titleCutout: TextNodeCutout? if !titleLeftCutout.isZero { titleCutout = TextNodeCutout(topLeft: CGSize(width: titleLeftCutout, height: 10.0), topRight: nil, bottomRight: nil) } + + var titleBadgeLayoutAndApply: (TextNodeLayout, () -> TextNode)? + if let titleBadgeText { + let titleBadgeLayoutAndApplyValue = titleBadgeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleBadgeText, font: Font.semibold(11.0), textColor: theme.titleColor.withMultipliedAlpha(0.4)), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + titleBadgeLayoutAndApply = titleBadgeLayoutAndApplyValue + titleRectWidth = max(10.0, titleRectWidth - titleBadgeLayoutAndApplyValue.0.size.width - 8.0) + } + let (titleLayout, titleApply) = titleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: maxTitleLines, truncationType: .end, constrainedSize: CGSize(width: titleRectWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: titleCutout, insets: UIEdgeInsets())) var inputActivitiesSize: CGSize? @@ -4244,6 +4263,36 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { let contentDelta = CGPoint(x: contentRect.origin.x - (strongSelf.titleNode.frame.minX - titleOffset), y: contentRect.origin.y - (strongSelf.titleNode.frame.minY - UIScreenPixel)) let titleFrame = CGRect(origin: CGPoint(x: contentRect.origin.x + titleOffset, y: contentRect.origin.y + UIScreenPixel), size: titleLayout.size) strongSelf.titleNode.frame = titleFrame + + if let (titleBadgeLayout, titleBadgeApply) = titleBadgeLayoutAndApply { + let titleBadgeNode = titleBadgeApply() + let backgroundView: UIImageView + if let current = strongSelf.titleBadge { + backgroundView = current.backgroundView + } else { + backgroundView = UIImageView(image: generateStretchableFilledCircleImage(radius: 4.0, color: .white)?.withRenderingMode(.alwaysTemplate)) + strongSelf.titleBadge = (backgroundView, titleBadgeNode) + + strongSelf.mainContentContainerNode.view.addSubview(backgroundView) + strongSelf.mainContentContainerNode.addSubnode(titleBadgeNode) + } + let titleBadgeFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + titleIconsWidth + 10.0, y: titleFrame.minY + floor((titleFrame.height - titleBadgeLayout.size.height) * 0.5)), size: titleBadgeLayout.size) + titleBadgeNode.frame = titleBadgeFrame + + var titleBadgeBackgroundFrame = titleBadgeFrame.insetBy(dx: -4.0, dy: -2.0) + titleBadgeBackgroundFrame.size.height -= 1.0 + backgroundView.frame = titleBadgeBackgroundFrame + if item.presentationData.theme.overallDarkAppearance { + backgroundView.tintColor = theme.titleColor.withMultipliedAlpha(0.1) + } else { + backgroundView.tintColor = theme.titleColor.withMultipliedAlpha(0.05) + } + } else if let titleBadge = strongSelf.titleBadge { + strongSelf.titleBadge = nil + titleBadge.backgroundView.removeFromSuperview() + titleBadge.textNode.removeFromSupernode() + } + let authorNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height), size: authorLayout) strongSelf.authorNode.frame = authorNodeFrame let textNodeFrame = CGRect(origin: CGPoint(x: contentRect.origin.x - 1.0, y: contentRect.minY + titleLayout.size.height - 1.0 + UIScreenPixel + (authorLayout.height.isZero ? 0.0 : (authorLayout.height - 3.0))), size: textLayout.size) diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index c2c7180505..026dfa2641 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -468,7 +468,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL interaction: nodeInteraction ), directionHint: entry.directionHint) case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout, displayPresence): - let itemPeer = peer.chatMainPeer + let itemPeer = peer.chatOrMonoforumMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { chatPeer = peer @@ -643,7 +643,7 @@ private func mappedInsertEntries(context: AccountContext, nodeInteraction: ChatL animationRenderer: nodeInteraction.animationRenderer ), directionHint: entry.directionHint) case .peerType: - let itemPeer = peer.chatMainPeer + let itemPeer = peer.chatOrMonoforumMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { chatPeer = peer @@ -867,7 +867,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL interaction: nodeInteraction ), directionHint: entry.directionHint) case let .peers(filter, isSelecting, _, filters, displayAutoremoveTimeout, displayPresence): - let itemPeer = peer.chatMainPeer + let itemPeer = peer.chatOrMonoforumMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { chatPeer = peer @@ -993,7 +993,7 @@ private func mappedUpdateEntries(context: AccountContext, nodeInteraction: ChatL animationRenderer: nodeInteraction.animationRenderer ), directionHint: entry.directionHint) case .peerType: - let itemPeer = peer.chatMainPeer + let itemPeer = peer.chatOrMonoforumMainPeer var chatPeer: EnginePeer? if let peer = peer.peers[peer.peerId] { chatPeer = peer diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index 5dde9d68e0..d196265547 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -16,8 +16,8 @@ public extension UIView { } } -private extension CALayer { - func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) { +public extension CALayer { + func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil, key: String? = nil) { let timingFunction: String let mediaTimingFunction: CAMediaTimingFunction? switch curve { @@ -39,7 +39,8 @@ private extension CALayer { mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, - completion: completion + completion: completion, + key: key ) } } diff --git a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift index ddc2f0a963..8cb08592bf 100644 --- a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift +++ b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift @@ -1211,7 +1211,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { strongSelf.contextSourceNode.contentRect = extractedRect switch item.peer { - case let .peer(peer, _): + case let .peer(peer, chatPeer): if let peer = peer { var overrideImage: AvatarNodeImageOverride? if peer.id == item.context.account.peerId, case let .generalSearch(isSavedMessages) = item.peerMode, case .treatSelfAsSaved = item.aliasHandling { @@ -1233,8 +1233,16 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { if case .app(true) = item.peerMode { clipStyle = .roundedRect displayDimensions = CGSize(width: displayDimensions.width, height: displayDimensions.width * 1.2) - } else if case let .channel(channel) = peer, channel.isForumOrMonoForum { - clipStyle = .roundedRect + } else if case let .channel(channel) = peer { + if case let .channel(chatPeer) = chatPeer, chatPeer.isMonoForum { + clipStyle = .bubble + } else { + if channel.isForum { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + } } else { clipStyle = .round } diff --git a/submodules/Display/Source/ListView.swift b/submodules/Display/Source/ListView.swift index 9443d324d1..99044f64e6 100644 --- a/submodules/Display/Source/ListView.swift +++ b/submodules/Display/Source/ListView.swift @@ -263,6 +263,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel public final var didScrollWithOffset: ((CGFloat, ContainedViewLayoutTransition, ListViewItemNode?, Bool) -> Void)? public final var addContentOffset: ((CGFloat, ListViewItemNode?) -> Void)? public final var shouldStopScrolling: ((CGFloat) -> Bool)? + public final var onContentsUpdated: ((ContainedViewLayoutTransition) -> Void)? public final var updateScrollingIndicator: ((ScrollingIndicatorState?, ContainedViewLayoutTransition) -> Void)? @@ -389,6 +390,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel private var reorderScrollUpdateTimestamp: Double? private var reorderLastTimestamp: Double? public var reorderedItemHasShadow = true + public var reorderingRequiresLongPress = false private let waitingForNodesDisposable = MetaDisposable() @@ -518,7 +520,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel let itemNodeFrame = itemNode.frame let itemNodeBounds = itemNode.bounds if itemNode.isReorderable(at: point.offsetBy(dx: -itemNodeFrame.minX + itemNodeBounds.minX, dy: -itemNodeFrame.minY + itemNodeBounds.minY)) { - let requiresLongPress = !strongSelf.reorderedItemHasShadow + let requiresLongPress = strongSelf.reorderingRequiresLongPress return (true, requiresLongPress, itemNode) } break @@ -1101,6 +1103,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel self.updateVisibleContentOffset() self.updateVisibleItemRange() self.updateItemNodesVisibilities(onlyPositive: false) + self.onContentsUpdated?(.immediate) //CATransaction.commit() } @@ -3509,6 +3512,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel } self.updateItemHeaders(leftInset: listInsets.left, rightInset: listInsets.right, synchronousLoad: synchronousLoads, transition: headerNodesTransition, animateInsertion: animated || !requestItemInsertionAnimationsIndices.isEmpty, animateFullTransition: animateFullTransition) + self.onContentsUpdated?(headerNodesTransition.0) if let offset = offset, !offset.isZero { //self.didScrollWithOffset?(-offset, headerNodesTransition.0, nil) @@ -3733,6 +3737,7 @@ open class ListView: ASDisplayNode, ASScrollViewDelegate, ASGestureRecognizerDel } else { self.updateItemHeaders(leftInset: listInsets.left, rightInset: listInsets.right, synchronousLoad: synchronousLoads, transition: headerNodesTransition, animateInsertion: animated || !requestItemInsertionAnimationsIndices.isEmpty, animateFullTransition: animateFullTransition) self.updateItemNodesVisibilities(onlyPositive: deferredUpdateVisible) + self.onContentsUpdated?(headerNodesTransition.0) applyHeaderNodesFullTransition() diff --git a/submodules/Display/Source/ListViewAnimation.swift b/submodules/Display/Source/ListViewAnimation.swift index 44f51677a3..137c0fd16f 100644 --- a/submodules/Display/Source/ListViewAnimation.swift +++ b/submodules/Display/Source/ListViewAnimation.swift @@ -129,7 +129,7 @@ public func listViewAnimationCurveFromAnimationOptions(animationOptions: UIView. public final class ListViewAnimation { let from: Interpolatable - let to: Interpolatable + public let to: Interpolatable let duration: Double let startTime: Double let invertOffsetDirection: Bool diff --git a/submodules/Postbox/Sources/MessageHistoryView.swift b/submodules/Postbox/Sources/MessageHistoryView.swift index 61d5a7cfd2..94b0a438a5 100644 --- a/submodules/Postbox/Sources/MessageHistoryView.swift +++ b/submodules/Postbox/Sources/MessageHistoryView.swift @@ -679,13 +679,16 @@ final class MutableMessageHistoryView: MutablePostboxView { hasChanges = true } case let .UpdateReadState(peerId, combinedReadState): - hasChanges = true - if let transientReadStates = self.transientReadStates { - switch transientReadStates { - case let .peer(states): - var updatedStates = states - updatedStates[peerId] = combinedReadState - self.transientReadStates = .peer(updatedStates) + if case let .single(_, threadId) = self.peerIds, threadId != nil { + } else { + hasChanges = true + if let transientReadStates = self.transientReadStates { + switch transientReadStates { + case let .peer(states): + var updatedStates = states + updatedStates[peerId] = combinedReadState + self.transientReadStates = .peer(updatedStates) + } } } case let .UpdateTimestamp(index, timestamp): @@ -698,8 +701,15 @@ final class MutableMessageHistoryView: MutablePostboxView { var currentThreadId: Int64? switch self.peerIds { - case let .single(_, threadIdValue): + case let .single(peerId, threadIdValue): currentThreadId = threadIdValue + + if let threadIdValue, transaction.updatedPeerThreadInfos.contains(MessageHistoryThreadsTable.ItemId(peerId: peerId, threadId: threadIdValue)) { + if let threadData = postbox.messageHistoryThreadIndexTable.get(peerId: peerId, threadId: threadIdValue) { + hasChanges = true + self.transientReadStates = .peer([peerId: CombinedPeerReadState(states: [(0, .idBased(maxIncomingReadId: 0, maxOutgoingReadId: threadData.summary.maxOutgoingReadId, maxKnownId: 0, count: 0, markedUnread: false))])]) + } + } case .associated: break case .external: diff --git a/submodules/Postbox/Sources/MessageThreadIndexTable.swift b/submodules/Postbox/Sources/MessageThreadIndexTable.swift index 9e3c2aec2d..fa39627fa6 100644 --- a/submodules/Postbox/Sources/MessageThreadIndexTable.swift +++ b/submodules/Postbox/Sources/MessageThreadIndexTable.swift @@ -5,17 +5,20 @@ public struct StoredMessageHistoryThreadInfo: Equatable, PostboxCoding { public var totalUnreadCount: Int32 public var isMarkedUnread: Bool public var mutedUntil: Int32? + public var maxOutgoingReadId: Int32 - public init(totalUnreadCount: Int32, isMarkedUnread: Bool, mutedUntil: Int32?) { + public init(totalUnreadCount: Int32, isMarkedUnread: Bool, mutedUntil: Int32?, maxOutgoingReadId: Int32) { self.totalUnreadCount = totalUnreadCount self.isMarkedUnread = isMarkedUnread self.mutedUntil = mutedUntil + self.maxOutgoingReadId = maxOutgoingReadId } public init(decoder: PostboxDecoder) { self.totalUnreadCount = decoder.decodeInt32ForKey("u", orElse: 0) self.mutedUntil = decoder.decodeOptionalInt32ForKey("m") self.isMarkedUnread = decoder.decodeBoolForKey("mu", orElse: false) + self.maxOutgoingReadId = decoder.decodeInt32ForKey("or", orElse: 0) } public func encode(_ encoder: PostboxEncoder) { @@ -26,6 +29,7 @@ public struct StoredMessageHistoryThreadInfo: Equatable, PostboxCoding { } else { encoder.encodeNil(forKey: "m") } + encoder.encodeInt32(self.maxOutgoingReadId, forKey: "or") } } @@ -112,7 +116,7 @@ class MessageHistoryThreadIndexTable: Table { return ValueBoxTable(id: id, keyType: .binary, compactValuesOnCreation: true) } - private struct UpdatedEntry { + struct UpdatedEntry { var value: StoredMessageHistoryThreadInfo? } @@ -127,7 +131,7 @@ class MessageHistoryThreadIndexTable: Table { private let sharedKey = ValueBoxKey(length: 8 + 4 + 8 + 4 + 4) - private var updatedInfoItems: [MessageHistoryThreadsTable.ItemId: UpdatedEntry] = [:] + private(set) var updatedInfoItems: [MessageHistoryThreadsTable.ItemId: UpdatedEntry] = [:] init(valueBox: ValueBox, table: ValueBoxTable, reverseIndexTable: MessageHistoryThreadReverseIndexTable, seedConfiguration: SeedConfiguration, useCaches: Bool) { self.reverseIndexTable = reverseIndexTable diff --git a/submodules/Postbox/Sources/Postbox.swift b/submodules/Postbox/Sources/Postbox.swift index cf1cca5917..893f784fec 100644 --- a/submodules/Postbox/Sources/Postbox.swift +++ b/submodules/Postbox/Sources/Postbox.swift @@ -2456,6 +2456,7 @@ final class PostboxImpl { let transactionParticipationInTotalUnreadCountUpdates = self.peerNotificationSettingsTable.transactionParticipationInTotalUnreadCountUpdates(postbox: self, transaction: currentTransaction) let updatedMessageThreadPeerIds = self.messageHistoryThreadIndexTable.replay(threadsTable: self.messageHistoryThreadsTable, namespaces: self.seedConfiguration.chatMessagesNamespaces, updatedIds: self.messageHistoryThreadsTable.updatedIds) + let updatedPeerThreadInfos = Set(self.messageHistoryThreadIndexTable.updatedInfoItems.keys) let alteredInitialPeerThreadsSummaries = self.peerThreadsSummaryTable.update(peerIds: updatedMessageThreadPeerIds.union(self.currentUpdatedPeerThreadCombinedStates), indexTable: self.messageHistoryThreadIndexTable, combinedStateTable: self.peerThreadCombinedStateTable, tagsSummaryTable: self.messageHistoryTagsSummaryTable) self.chatListIndexTable.commitWithTransaction( @@ -2479,7 +2480,62 @@ final class PostboxImpl { let updatedPeerTimeoutAttributes = self.peerTimeoutPropertiesTable.hasUpdates - let transaction = PostboxTransaction(currentUpdatedState: self.currentUpdatedState, currentPeerHoleOperations: self.currentPeerHoleOperations, currentOperationsByPeerId: self.currentOperationsByPeerId, chatListOperations: self.currentChatListOperations, currentUpdatedChatListInclusions: self.currentUpdatedChatListInclusions, currentUpdatedPeers: self.currentUpdatedPeers, currentUpdatedPeerNotificationSettings: self.currentUpdatedPeerNotificationSettings, currentUpdatedPeerNotificationBehaviorTimestamps: self.currentUpdatedPeerNotificationBehaviorTimestamps, currentUpdatedCachedPeerData: self.currentUpdatedCachedPeerData, currentUpdatedPeerPresences: currentUpdatedPeerPresences, currentUpdatedPeerChatListEmbeddedStates: self.currentUpdatedPeerChatListEmbeddedStates, currentUpdatedTotalUnreadStates: self.currentUpdatedTotalUnreadStates, currentUpdatedTotalUnreadSummaries: self.currentUpdatedGroupTotalUnreadSummaries, alteredInitialPeerCombinedReadStates: alteredInitialPeerCombinedReadStates, currentPeerMergedOperationLogOperations: self.currentPeerMergedOperationLogOperations, currentTimestampBasedMessageAttributesOperations: self.currentTimestampBasedMessageAttributesOperations, unsentMessageOperations: self.currentUnsentOperations, updatedSynchronizePeerReadStateOperations: self.currentUpdatedSynchronizeReadStateOperations, currentUpdatedGroupSummarySynchronizeOperations: self.currentUpdatedGroupSummarySynchronizeOperations, currentPreferencesOperations: self.currentPreferencesOperations, currentOrderedItemListOperations: self.currentOrderedItemListOperations, currentItemCollectionItemsOperations: self.currentItemCollectionItemsOperations, currentItemCollectionInfosOperations: self.currentItemCollectionInfosOperations, currentUpdatedPeerChatStates: self.currentUpdatedPeerChatStates, currentGlobalTagsOperations: self.currentGlobalTagsOperations, currentLocalTagsOperations: self.currentLocalTagsOperations, updatedMedia: self.currentUpdatedMedia, replaceRemoteContactCount: self.currentReplaceRemoteContactCount, replaceContactPeerIds: self.currentReplacedContactPeerIds, currentPendingMessageActionsOperations: self.currentPendingMessageActionsOperations, currentUpdatedMessageActionsSummaries: self.currentUpdatedMessageActionsSummaries, currentUpdatedMessageTagSummaries: self.currentUpdatedMessageTagSummaries, currentInvalidateMessageTagSummaries: self.currentInvalidateMessageTagSummaries, currentUpdatedPendingPeerNotificationSettings: self.currentUpdatedPendingPeerNotificationSettings, replacedAdditionalChatListItems: self.currentReplacedAdditionalChatListItems, updatedNoticeEntryKeys: self.currentUpdatedNoticeEntryKeys, updatedCacheEntryKeys: self.currentUpdatedCacheEntryKeys, currentUpdatedMasterClientId: currentUpdatedMasterClientId, updatedFailedMessagePeerIds: self.messageHistoryFailedTable.updatedPeerIds, updatedFailedMessageIds: self.messageHistoryFailedTable.updatedMessageIds, updatedGlobalNotificationSettings: self.currentNeedsReindexUnreadCounters, updatedPeerTimeoutAttributes: updatedPeerTimeoutAttributes, updatedMessageThreadPeerIds: updatedMessageThreadPeerIds, updatedPeerThreadCombinedStates: self.currentUpdatedPeerThreadCombinedStates, updatedPeerThreadsSummaries: Set(alteredInitialPeerThreadsSummaries.keys), updatedPinnedThreads: self.currentUpdatedPinnedThreads, updatedHiddenPeerIds: self.currentUpdatedHiddenPeerIds, storyGeneralStatesEvents: self.currentStoryGeneralStatesEvents, storyPeerStatesEvents: self.currentStoryPeerStatesEvents, storySubscriptionsEvents: self.currentStorySubscriptionsEvents, storyItemsEvents: self.currentStoryItemsEvents, currentStoryTopItemEvents: self.currentStoryTopItemEvents, storyEvents: self.currentStoryEvents) + let transaction = PostboxTransaction( + currentUpdatedState: self.currentUpdatedState, + currentPeerHoleOperations: self.currentPeerHoleOperations, + currentOperationsByPeerId: self.currentOperationsByPeerId, + chatListOperations: self.currentChatListOperations, + currentUpdatedChatListInclusions: self.currentUpdatedChatListInclusions, + currentUpdatedPeers: self.currentUpdatedPeers, + currentUpdatedPeerNotificationSettings: self.currentUpdatedPeerNotificationSettings, + currentUpdatedPeerNotificationBehaviorTimestamps: self.currentUpdatedPeerNotificationBehaviorTimestamps, + currentUpdatedCachedPeerData: self.currentUpdatedCachedPeerData, + currentUpdatedPeerPresences: currentUpdatedPeerPresences, + currentUpdatedPeerChatListEmbeddedStates: self.currentUpdatedPeerChatListEmbeddedStates, + currentUpdatedTotalUnreadStates: self.currentUpdatedTotalUnreadStates, + currentUpdatedTotalUnreadSummaries: self.currentUpdatedGroupTotalUnreadSummaries, + alteredInitialPeerCombinedReadStates: alteredInitialPeerCombinedReadStates, + currentPeerMergedOperationLogOperations: self.currentPeerMergedOperationLogOperations, + currentTimestampBasedMessageAttributesOperations: self.currentTimestampBasedMessageAttributesOperations, + unsentMessageOperations: self.currentUnsentOperations, + updatedSynchronizePeerReadStateOperations: self.currentUpdatedSynchronizeReadStateOperations, + currentUpdatedGroupSummarySynchronizeOperations: self.currentUpdatedGroupSummarySynchronizeOperations, + currentPreferencesOperations: self.currentPreferencesOperations, + currentOrderedItemListOperations: self.currentOrderedItemListOperations, + currentItemCollectionItemsOperations: self.currentItemCollectionItemsOperations, + currentItemCollectionInfosOperations: self.currentItemCollectionInfosOperations, + currentUpdatedPeerChatStates: self.currentUpdatedPeerChatStates, + currentGlobalTagsOperations: self.currentGlobalTagsOperations, + currentLocalTagsOperations: self.currentLocalTagsOperations, + updatedMedia: self.currentUpdatedMedia, + replaceRemoteContactCount: self.currentReplaceRemoteContactCount, + replaceContactPeerIds: self.currentReplacedContactPeerIds, + currentPendingMessageActionsOperations: self.currentPendingMessageActionsOperations, + currentUpdatedMessageActionsSummaries: self.currentUpdatedMessageActionsSummaries, + currentUpdatedMessageTagSummaries: self.currentUpdatedMessageTagSummaries, + currentInvalidateMessageTagSummaries: self.currentInvalidateMessageTagSummaries, + currentUpdatedPendingPeerNotificationSettings: self.currentUpdatedPendingPeerNotificationSettings, + replacedAdditionalChatListItems: self.currentReplacedAdditionalChatListItems, + updatedNoticeEntryKeys: self.currentUpdatedNoticeEntryKeys, + updatedCacheEntryKeys: self.currentUpdatedCacheEntryKeys, + currentUpdatedMasterClientId: currentUpdatedMasterClientId, + updatedFailedMessagePeerIds: self.messageHistoryFailedTable.updatedPeerIds, + updatedFailedMessageIds: self.messageHistoryFailedTable.updatedMessageIds, + updatedGlobalNotificationSettings: self.currentNeedsReindexUnreadCounters, + updatedPeerTimeoutAttributes: updatedPeerTimeoutAttributes, + updatedMessageThreadPeerIds: updatedMessageThreadPeerIds, + updatedPeerThreadCombinedStates: self.currentUpdatedPeerThreadCombinedStates, + updatedPeerThreadsSummaries: Set(alteredInitialPeerThreadsSummaries.keys), + updatedPeerThreadInfos: updatedPeerThreadInfos, + updatedPinnedThreads: self.currentUpdatedPinnedThreads, + updatedHiddenPeerIds: self.currentUpdatedHiddenPeerIds, + storyGeneralStatesEvents: self.currentStoryGeneralStatesEvents, + storyPeerStatesEvents: self.currentStoryPeerStatesEvents, + storySubscriptionsEvents: self.currentStorySubscriptionsEvents, + storyItemsEvents: self.currentStoryItemsEvents, + currentStoryTopItemEvents: self.currentStoryTopItemEvents, + storyEvents: self.currentStoryEvents + ) var updatedTransactionState: Int64? var updatedMasterClientId: Int64? if !transaction.isEmpty { @@ -3131,6 +3187,7 @@ final class PostboxImpl { let signal: Signal<(MessageHistoryView, ViewUpdateType, InitialMessageHistoryData?), NoError> = self.transactionSignal(userInteractive: true, { subscriber, transaction in let peerIds = self.peerIdsForLocation(chatLocation, ignoreRelatedChats: false) + //TODO:release jump to unread var anchor: HistoryViewInputAnchor = .upperBound switch peerIds { case let .single(peerId, threadId): @@ -3334,8 +3391,14 @@ final class PostboxImpl { var transientReadStates: MessageHistoryViewReadState? switch peerIds { case let .single(peerId, threadId): - if threadId == nil, let readState = self.readStateTable.getCombinedState(peerId) { - transientReadStates = .peer([peerId: readState]) + if let threadId { + if let threadData = self.messageHistoryThreadIndexTable.get(peerId: peerId, threadId: threadId) { + transientReadStates = .peer([peerId: CombinedPeerReadState(states: [(0, .idBased(maxIncomingReadId: 0, maxOutgoingReadId: threadData.summary.maxOutgoingReadId, maxKnownId: 0, count: 0, markedUnread: false))])]) + } + } else { + if let readState = self.readStateTable.getCombinedState(peerId) { + transientReadStates = .peer([peerId: readState]) + } } case let .associated(peerId, _): if let readState = self.readStateTable.getCombinedState(peerId) { diff --git a/submodules/Postbox/Sources/PostboxTransaction.swift b/submodules/Postbox/Sources/PostboxTransaction.swift index 5729f31e3f..0098e5b59c 100644 --- a/submodules/Postbox/Sources/PostboxTransaction.swift +++ b/submodules/Postbox/Sources/PostboxTransaction.swift @@ -47,6 +47,7 @@ final class PostboxTransaction { let updatedMessageThreadPeerIds: Set let updatedPeerThreadCombinedStates: Set let updatedPeerThreadsSummaries: Set + let updatedPeerThreadInfos: Set let updatedPinnedThreads: Set let updatedHiddenPeerIds: Bool let storyGeneralStatesEvents: [StoryGeneralStatesTable.Event] @@ -195,6 +196,9 @@ final class PostboxTransaction { if !self.updatedPeerThreadsSummaries.isEmpty { return false } + if !self.updatedPeerThreadInfos.isEmpty { + return false + } if !self.updatedPinnedThreads.isEmpty { return false } @@ -269,6 +273,7 @@ final class PostboxTransaction { updatedMessageThreadPeerIds: Set, updatedPeerThreadCombinedStates: Set, updatedPeerThreadsSummaries: Set, + updatedPeerThreadInfos: Set, updatedPinnedThreads: Set, updatedHiddenPeerIds: Bool, storyGeneralStatesEvents: [StoryGeneralStatesTable.Event], @@ -323,6 +328,7 @@ final class PostboxTransaction { self.updatedMessageThreadPeerIds = updatedMessageThreadPeerIds self.updatedPeerThreadCombinedStates = updatedPeerThreadCombinedStates self.updatedPeerThreadsSummaries = updatedPeerThreadsSummaries + self.updatedPeerThreadInfos = updatedPeerThreadInfos self.updatedPinnedThreads = updatedPinnedThreads self.updatedHiddenPeerIds = updatedHiddenPeerIds self.storyGeneralStatesEvents = storyGeneralStatesEvents diff --git a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift index d28906ef1a..f4ba6fd9fc 100644 --- a/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift +++ b/submodules/SelectablePeerNode/Sources/SelectablePeerNode.swift @@ -212,7 +212,7 @@ public final class SelectablePeerNode: ASDisplayNode { public func setup(accountPeerId: EnginePeer.Id, postbox: Postbox, network: Network, energyUsageSettings: EnergyUsageSettings, contentSettings: ContentSettings, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, resolveInlineStickers: @escaping ([Int64]) -> Signal<[Int64: TelegramMediaFile], NoError>, theme: PresentationTheme, strings: PresentationStrings, peer: EngineRenderedPeer, requiresPremiumForMessaging: Bool, requiresStars: Int64? = nil, customTitle: String? = nil, iconId: Int64? = nil, iconColor: Int32? = nil, online: Bool = false, numberOfLines: Int = 2, synchronousLoad: Bool) { let isFirstTime = self.peer == nil self.peer = peer - guard let mainPeer = peer.chatMainPeer else { + guard let mainPeer = peer.chatOrMonoforumMainPeer else { return } @@ -226,8 +226,10 @@ public final class SelectablePeerNode: ASDisplayNode { } var isForum = false - if let peer = peer.chatMainPeer, case let .channel(channel) = peer, channel.isForumOrMonoForum { - isForum = true + var isMonoforum = false + if let peer = peer.chatMainPeer, case let .channel(channel) = peer { + isForum = channel.isForum + isMonoforum = channel.isMonoForum } let text: String @@ -246,7 +248,15 @@ public final class SelectablePeerNode: ASDisplayNode { } self.textNode.maximumNumberOfLines = numberOfLines self.textNode.attributedText = NSAttributedString(string: customTitle ?? text, font: textFont, textColor: self.currentSelected ? self.theme.selectedTextColor : defaultColor, paragraphAlignment: .center) - self.avatarNode.setPeer(accountPeerId: accountPeerId, postbox: postbox, network: network, contentSettings: contentSettings, theme: theme, peer: mainPeer, overrideImage: overrideImage, emptyColor: self.theme.avatarPlaceholderColor, clipStyle: isForum ? .roundedRect : .round, synchronousLoad: synchronousLoad) + let clipStyle: AvatarNodeClipStyle + if isMonoforum { + clipStyle = .bubble + } else if isForum { + clipStyle = .roundedRect + } else { + clipStyle = .round + } + self.avatarNode.setPeer(accountPeerId: accountPeerId, postbox: postbox, network: network, contentSettings: contentSettings, theme: theme, peer: mainPeer, overrideImage: overrideImage, emptyColor: self.theme.avatarPlaceholderColor, clipStyle: clipStyle, synchronousLoad: synchronousLoad) if let requiresStars { let avatarBadgeOutline: UIImageView diff --git a/submodules/ShareController/Sources/ShareControllerNode.swift b/submodules/ShareController/Sources/ShareControllerNode.swift index 2addbd6134..ac5ac7f677 100644 --- a/submodules/ShareController/Sources/ShareControllerNode.swift +++ b/submodules/ShareController/Sources/ShareControllerNode.swift @@ -760,9 +760,14 @@ final class ShareControllerNode: ViewControllerTracingNode, ASScrollViewDelegate return } + var isMonoforum = false + if case let .channel(channel) = mainPeer { + isMonoforum = channel.isMonoForum + } + var didPresent = false var presentImpl: (() -> Void)? - let threads = threadList(accountPeerId: context.accountPeerId, postbox: context.stateManager.postbox, peerId: mainPeer.id) + let threads = threadList(accountPeerId: context.accountPeerId, postbox: context.stateManager.postbox, peerId: mainPeer.id, isMonoforum: isMonoforum) |> deliverOnMainQueue |> beforeNext { _ in if !didPresent { @@ -1933,78 +1938,162 @@ private final class ShareContextReferenceContentSource: ContextReferenceContentS } } -private func threadList(accountPeerId: EnginePeer.Id, postbox: Postbox, peerId: EnginePeer.Id) -> Signal { - let viewKey: PostboxViewKey = .messageHistoryThreadIndex( - id: peerId, - summaryComponents: ChatListEntrySummaryComponents( - components: [:] - ) - ) - - return postbox.combinedView(keys: [viewKey]) - |> mapToSignal { view -> Signal in - return postbox.transaction { transaction -> CombinedView in - if let peer = transaction.getPeer(accountPeerId) { - transaction.updatePeersInternal([peer]) { current, _ in - return current ?? peer +private func threadList(accountPeerId: EnginePeer.Id, postbox: Postbox, peerId: EnginePeer.Id, isMonoforum: Bool) -> Signal { + if isMonoforum { + let viewKey: PostboxViewKey = .savedMessagesIndex(peerId: peerId) + let interfaceStateKey: PostboxViewKey = .chatInterfaceState(peerId: peerId) + + return postbox.combinedView(keys: [viewKey, interfaceStateKey]) + |> map { views -> EngineChatList in + guard let view = views.views[viewKey] as? MessageHistorySavedMessagesIndexView else { + preconditionFailure() + } + + var draft: EngineChatList.Draft? + if let interfaceStateView = views.views[interfaceStateKey] as? ChatInterfaceStateView { + if let embeddedState = interfaceStateView.value, let _ = embeddedState.overrideChatTimestamp { + if let opaqueState = _internal_decodeStoredChatInterfaceState(state: embeddedState) { + if let text = opaqueState.synchronizeableInputState?.text { + draft = EngineChatList.Draft(text: text, entities: opaqueState.synchronizeableInputState?.entities ?? []) + } + } } } - return view - } - } - |> map { views -> EngineChatList in - guard let view = views.views[viewKey] as? MessageHistoryThreadIndexView else { - preconditionFailure() - } - - var items: [EngineChatList.Item] = [] - for item in view.items { - guard let peer = view.peer else { - continue - } - guard let data = item.info.get(MessageHistoryThreadData.self) else { - continue + + var items: [EngineChatList.Item] = [] + for item in view.items { + guard let sourcePeer = item.peer else { + continue + } + + let sourceId = PeerId(item.id) + + var messages: [EngineMessage] = [] + if let topMessage = item.topMessage { + messages.append(EngineMessage(topMessage)) + } + + let mappedMessageIndex = MessageIndex(id: MessageId(peerId: sourceId, namespace: item.index.id.namespace, id: item.index.id.id), timestamp: item.index.timestamp) + + let readCounters = EnginePeerReadCounters(state: CombinedPeerReadState(states: [(Namespaces.Message.Cloud, .idBased(maxIncomingReadId: 0, maxOutgoingReadId: 0, maxKnownId: 0, count: Int32(item.unreadCount), markedUnread: item.markedUnread))]), isMuted: false) + + var itemDraft: EngineChatList.Draft? + if let embeddedState = item.embeddedInterfaceState, let _ = embeddedState.overrideChatTimestamp { + if let opaqueState = _internal_decodeStoredChatInterfaceState(state: embeddedState) { + if let text = opaqueState.synchronizeableInputState?.text { + itemDraft = EngineChatList.Draft(text: text, entities: opaqueState.synchronizeableInputState?.entities ?? []) + } + } + } + + items.append(EngineChatList.Item( + id: .chatList(sourceId), + index: .chatList(ChatListIndex(pinningIndex: item.pinnedIndex.flatMap(UInt16.init), messageIndex: mappedMessageIndex)), + messages: messages, + readCounters: readCounters, + isMuted: false, + draft: sourceId == accountPeerId ? draft : itemDraft, + threadData: nil, + renderedPeer: EngineRenderedPeer(peer: EnginePeer(sourcePeer)), + presence: nil, + hasUnseenMentions: false, + hasUnseenReactions: false, + forumTopicData: nil, + topForumTopicItems: [], + hasFailed: false, + isContact: false, + autoremoveTimeout: nil, + storyStats: nil, + displayAsTopicList: false, + isPremiumRequiredToMessage: false, + mediaDraftContentType: nil + )) } - let pinnedIndex: EngineChatList.Item.PinnedIndex - if let index = item.pinnedIndex { - pinnedIndex = .index(index) - } else { - pinnedIndex = .none - } + let list = EngineChatList( + items: items.reversed(), + groupItems: [], + additionalItems: [], + hasEarlier: false, + hasLater: false, + isLoading: view.isLoading + ) - items.append(EngineChatList.Item( - id: .forum(item.id), - index: .forum(pinnedIndex: pinnedIndex, timestamp: item.index.timestamp, threadId: item.id, namespace: item.index.id.namespace, id: item.index.id.id), - messages: item.topMessage.flatMap { [EngineMessage($0)] } ?? [], - readCounters: nil, - isMuted: false, - draft: nil, - threadData: data, - renderedPeer: EngineRenderedPeer(peer: EnginePeer(peer)), - presence: nil, - hasUnseenMentions: false, - hasUnseenReactions: false, - forumTopicData: nil, - topForumTopicItems: [], - hasFailed: false, - isContact: false, - autoremoveTimeout: nil, - storyStats: nil, - displayAsTopicList: false, - isPremiumRequiredToMessage: false, - mediaDraftContentType: nil - )) + return list } - - let list = EngineChatList( - items: items, - groupItems: [], - additionalItems: [], - hasEarlier: false, - hasLater: false, - isLoading: view.isLoading + } else { + let viewKey: PostboxViewKey = .messageHistoryThreadIndex( + id: peerId, + summaryComponents: ChatListEntrySummaryComponents( + components: [:] + ) ) - return list + + return postbox.combinedView(keys: [viewKey]) + |> mapToSignal { view -> Signal in + return postbox.transaction { transaction -> CombinedView in + if let peer = transaction.getPeer(accountPeerId) { + transaction.updatePeersInternal([peer]) { current, _ in + return current ?? peer + } + } + return view + } + } + |> map { views -> EngineChatList in + guard let view = views.views[viewKey] as? MessageHistoryThreadIndexView else { + preconditionFailure() + } + + var items: [EngineChatList.Item] = [] + for item in view.items { + guard let peer = view.peer else { + continue + } + guard let data = item.info.get(MessageHistoryThreadData.self) else { + continue + } + + let pinnedIndex: EngineChatList.Item.PinnedIndex + if let index = item.pinnedIndex { + pinnedIndex = .index(index) + } else { + pinnedIndex = .none + } + + items.append(EngineChatList.Item( + id: .forum(item.id), + index: .forum(pinnedIndex: pinnedIndex, timestamp: item.index.timestamp, threadId: item.id, namespace: item.index.id.namespace, id: item.index.id.id), + messages: item.topMessage.flatMap { [EngineMessage($0)] } ?? [], + readCounters: nil, + isMuted: false, + draft: nil, + threadData: data, + renderedPeer: EngineRenderedPeer(peer: EnginePeer(peer)), + presence: nil, + hasUnseenMentions: false, + hasUnseenReactions: false, + forumTopicData: nil, + topForumTopicItems: [], + hasFailed: false, + isContact: false, + autoremoveTimeout: nil, + storyStats: nil, + displayAsTopicList: false, + isPremiumRequiredToMessage: false, + mediaDraftContentType: nil + )) + } + + let list = EngineChatList( + items: items, + groupItems: [], + additionalItems: [], + hasEarlier: false, + hasLater: false, + isLoading: view.isLoading + ) + return list + } } } diff --git a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift index 6a116d5a5b..f9ee2a4670 100644 --- a/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift +++ b/submodules/ShareController/Sources/ShareControllerPeerGridItem.swift @@ -16,14 +16,14 @@ final class ShareControllerInteraction { var selectedPeerIds = Set() var selectedPeers: [EngineRenderedPeer] = [] - var selectedTopics: [EnginePeer.Id: (Int64, MessageHistoryThreadData)] = [:] + var selectedTopics: [EnginePeer.Id: (Int64, MessageHistoryThreadData?)] = [:] let togglePeer: (EngineRenderedPeer, Bool) -> Void - let selectTopic: (EngineRenderedPeer, Int64, MessageHistoryThreadData) -> Void + let selectTopic: (EngineRenderedPeer, Int64, MessageHistoryThreadData?) -> Void let shareStory: (() -> Void)? let disabledPeerSelected: (EngineRenderedPeer) -> Void - init(togglePeer: @escaping (EngineRenderedPeer, Bool) -> Void, selectTopic: @escaping (EngineRenderedPeer, Int64, MessageHistoryThreadData) -> Void, shareStory: (() -> Void)?, disabledPeerSelected: @escaping (EngineRenderedPeer) -> Void) { + init(togglePeer: @escaping (EngineRenderedPeer, Bool) -> Void, selectTopic: @escaping (EngineRenderedPeer, Int64, MessageHistoryThreadData?) -> Void, shareStory: (() -> Void)?, disabledPeerSelected: @escaping (EngineRenderedPeer) -> Void) { self.togglePeer = togglePeer self.selectTopic = selectTopic self.shareStory = shareStory diff --git a/submodules/ShareController/Sources/ShareTopicGridItem.swift b/submodules/ShareController/Sources/ShareTopicGridItem.swift index de756596ae..015d0b7648 100644 --- a/submodules/ShareController/Sources/ShareTopicGridItem.swift +++ b/submodules/ShareController/Sources/ShareTopicGridItem.swift @@ -12,22 +12,25 @@ import AccountContext import ShimmerEffect import ComponentFlow import EmojiStatusComponent +import AvatarNode final class ShareTopicGridItem: GridItem { let environment: ShareControllerEnvironment let context: ShareControllerAccountContext let theme: PresentationTheme let strings: PresentationStrings + let basePeer: EnginePeer let peer: EngineRenderedPeer? let id: Int64 - let threadInfo: MessageHistoryThreadData + let threadInfo: MessageHistoryThreadData? let controllerInteraction: ShareControllerInteraction let section: GridSection? - init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, theme: PresentationTheme, strings: PresentationStrings, peer: EngineRenderedPeer?, id: Int64, threadInfo: MessageHistoryThreadData, controllerInteraction: ShareControllerInteraction) { + init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, theme: PresentationTheme, strings: PresentationStrings, basePeer: EnginePeer, peer: EngineRenderedPeer?, id: Int64, threadInfo: MessageHistoryThreadData?, controllerInteraction: ShareControllerInteraction) { self.environment = environment self.context = context + self.basePeer = basePeer self.theme = theme self.strings = strings self.peer = peer @@ -52,6 +55,7 @@ final class ShareTopicGridItemNode: GridItemNode { private let iconView: ComponentView private let textNode: ImmediateTextNode + private var avatarNode: AvatarNode? private var placeholderNode: ShimmerEffectNode? private var absoluteLocation: (CGRect, CGSize)? @@ -80,7 +84,11 @@ final class ShareTopicGridItemNode: GridItemNode { @objc private func tapped() { if let item = self.currentItem, let peer = item.peer { - item.controllerInteraction.selectTopic(peer, item.id, item.threadInfo) + if let threadInfo = item.threadInfo { + item.controllerInteraction.selectTopic(peer, item.id, threadInfo) + } else { + item.controllerInteraction.selectTopic(EngineRenderedPeer(peer: item.basePeer), item.id, nil) + } } } @@ -100,40 +108,59 @@ final class ShareTopicGridItemNode: GridItemNode { } self.currentItem = item - self.textNode.attributedText = NSAttributedString(string: item.threadInfo.info.title, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) + if let threadInfo = item.threadInfo { + self.textNode.attributedText = NSAttributedString(string: threadInfo.info.title, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) + + let iconContent: EmojiStatusComponent.Content + if let fileId = threadInfo.info.icon { + iconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 96.0, height: 96.0), placeholderColor: item.theme.actionSheet.disabledActionTextColor, themeColor: item.theme.actionSheet.primaryTextColor, loopMode: .count(0)) + } else { + iconContent = .topic(title: String(threadInfo.info.title.prefix(1)), color: threadInfo.info.iconColor, size: CGSize(width: 64.0, height: 64.0)) + } + + let iconSize = self.iconView.update( + transition: .easeInOut(duration: 0.2), + component: AnyComponent(EmojiStatusComponent( + postbox: item.context.stateManager.postbox, + energyUsageSettings: item.environment.energyUsageSettings, + resolveInlineStickers: item.context.resolveInlineStickers, + animationCache: item.context.animationCache, + animationRenderer: item.context.animationRenderer, + content: iconContent, + isVisibleForAnimations: true, + action: nil + )), + environment: {}, + containerSize: CGSize(width: 54.0, height: 54.0) + ) + + if let iconComponentView = self.iconView.view { + if iconComponentView.superview == nil { + self.view.addSubview(iconComponentView) + } + iconComponentView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: 7.0), size: iconSize) + } + } else if let peer = item.peer, let mainPeer = peer.chatMainPeer { + self.textNode.attributedText = NSAttributedString(string: mainPeer.compactDisplayTitle, font: Font.regular(11.0), textColor: item.theme.actionSheet.primaryTextColor) + + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0)) + self.avatarNode = avatarNode + self.addSubnode(avatarNode) + } + let iconSize = CGSize(width: 54.0, height: 54.0) + avatarNode.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: 7.0), size: iconSize) + avatarNode.updateSize(size: iconSize) + + avatarNode.setPeer(accountPeerId: item.context.accountPeerId, postbox: item.context.stateManager.postbox, network: item.context.stateManager.network, contentSettings: ContentSettings.default, theme: item.theme, peer: mainPeer, overrideImage: nil, emptyColor: item.theme.list.mediaPlaceholderColor, clipStyle: .round, synchronousLoad: false) + } + let textSize = self.textNode.updateLayout(size) let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) / 2.0), y: 4.0 + 60.0 + 4.0), size: textSize) self.textNode.frame = textFrame - - let iconContent: EmojiStatusComponent.Content - if let fileId = item.threadInfo.info.icon { - iconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 96.0, height: 96.0), placeholderColor: item.theme.actionSheet.disabledActionTextColor, themeColor: item.theme.actionSheet.primaryTextColor, loopMode: .count(0)) - } else { - iconContent = .topic(title: String(item.threadInfo.info.title.prefix(1)), color: item.threadInfo.info.iconColor, size: CGSize(width: 64.0, height: 64.0)) - } - - let iconSize = self.iconView.update( - transition: .easeInOut(duration: 0.2), - component: AnyComponent(EmojiStatusComponent( - postbox: item.context.stateManager.postbox, - energyUsageSettings: item.environment.energyUsageSettings, - resolveInlineStickers: item.context.resolveInlineStickers, - animationCache: item.context.animationCache, - animationRenderer: item.context.animationRenderer, - content: iconContent, - isVisibleForAnimations: true, - action: nil - )), - environment: {}, - containerSize: CGSize(width: 54.0, height: 54.0) - ) - - if let iconComponentView = self.iconView.view { - if iconComponentView.superview == nil { - self.view.addSubview(iconComponentView) - } - iconComponentView.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: 7.0), size: iconSize) - } } override func layout() { diff --git a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift index 5462d91491..18d5b6e4a2 100644 --- a/submodules/ShareController/Sources/ShareTopicsContainerNode.swift +++ b/submodules/ShareController/Sources/ShareTopicsContainerNode.swift @@ -18,9 +18,10 @@ private let subtitleFont = Font.regular(12.0) private struct ShareTopicEntry: Comparable, Identifiable { let index: Int32 + let basePeer: EnginePeer let peer: EngineRenderedPeer let id: Int64 - let threadData: MessageHistoryThreadData + let threadData: MessageHistoryThreadData? let theme: PresentationTheme let strings: PresentationStrings @@ -32,6 +33,9 @@ private struct ShareTopicEntry: Comparable, Identifiable { if lhs.index != rhs.index { return false } + if lhs.basePeer != rhs.basePeer { + return false + } if lhs.peer != rhs.peer { return false } @@ -53,7 +57,7 @@ private struct ShareTopicEntry: Comparable, Identifiable { } func item(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, interfaceInteraction: ShareControllerInteraction) -> GridItem { - return ShareTopicGridItem(environment: environment, context: context, theme: self.theme, strings: self.strings, peer: self.peer, id: self.id, threadInfo: self.threadData, controllerInteraction: interfaceInteraction) + return ShareTopicGridItem(environment: environment, context: context, theme: self.theme, strings: self.strings, basePeer: self.basePeer, peer: self.peer, id: self.id, threadInfo: self.threadData, controllerInteraction: interfaceInteraction) } } @@ -205,7 +209,10 @@ final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode { for topic in topics { if case let .forum(_, _, threadId, _, _) = topic.index, let threadData = topic.threadData { - entries.append(ShareTopicEntry(index: index, peer: EngineRenderedPeer(peer: peer), id: threadId, threadData: threadData, theme: theme, strings: strings)) + entries.append(ShareTopicEntry(index: index, basePeer: peer, peer: EngineRenderedPeer(peer: peer), id: threadId, threadData: threadData, theme: theme, strings: strings)) + index += 1 + } else if case .chatList = topic.index { + entries.append(ShareTopicEntry(index: index, basePeer: peer, peer: topic.renderedPeer, id: topic.renderedPeer.peerId.toInt64(), threadData: nil, theme: theme, strings: strings)) index += 1 } } diff --git a/submodules/TelegramApi/Sources/Api38.swift b/submodules/TelegramApi/Sources/Api38.swift index f244a6ac73..acc66397d7 100644 --- a/submodules/TelegramApi/Sources/Api38.swift +++ b/submodules/TelegramApi/Sources/Api38.swift @@ -8923,13 +8923,14 @@ public extension Api.functions.messages { } } public extension Api.functions.messages { - static func unpinAllMessages(flags: Int32, peer: Api.InputPeer, topMsgId: Int32?) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { + static func unpinAllMessages(flags: Int32, peer: Api.InputPeer, topMsgId: Int32?, savedPeerId: Api.InputPeer?) -> (FunctionDescription, Buffer, DeserializeFunctionResponse) { let buffer = Buffer() - buffer.appendInt32(-299714136) + buffer.appendInt32(103667527) serializeInt32(flags, buffer: buffer, boxed: false) peer.serialize(buffer, true) if Int(flags) & Int(1 << 0) != 0 {serializeInt32(topMsgId!, buffer: buffer, boxed: false)} - return (FunctionDescription(name: "messages.unpinAllMessages", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("topMsgId", String(describing: topMsgId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.AffectedHistory? in + if Int(flags) & Int(1 << 1) != 0 {savedPeerId!.serialize(buffer, true)} + return (FunctionDescription(name: "messages.unpinAllMessages", parameters: [("flags", String(describing: flags)), ("peer", String(describing: peer)), ("topMsgId", String(describing: topMsgId)), ("savedPeerId", String(describing: savedPeerId))]), buffer, DeserializeFunctionResponse { (buffer: Buffer) -> Api.messages.AffectedHistory? in let reader = BufferReader(buffer) var result: Api.messages.AffectedHistory? if let signature = reader.readInt32() { diff --git a/submodules/TelegramCore/Sources/ForumChannels.swift b/submodules/TelegramCore/Sources/ForumChannels.swift index a114815f47..2343e2d27a 100644 --- a/submodules/TelegramCore/Sources/ForumChannels.swift +++ b/submodules/TelegramCore/Sources/ForumChannels.swift @@ -161,7 +161,8 @@ extension StoredMessageHistoryThreadInfo { self.init(data: entry, summary: Summary( totalUnreadCount: data.incomingUnreadCount, isMarkedUnread: data.isMarkedUnread, - mutedUntil: mutedUntil + mutedUntil: mutedUntil, + maxOutgoingReadId: data.maxOutgoingReadId )) } } diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index 7f99ebc3e2..a24348e934 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4262,7 +4262,7 @@ func replayFinalState( } else { updatedIncomingThreadReadStates[peerAndThreadId] = readMaxId } - if let channel = transaction.getPeer(peerAndThreadId.peerId) as? TelegramChannel, case .group = channel.info, channel.flags.contains(.isForum) { + if let channel = transaction.getPeer(peerAndThreadId.peerId) as? TelegramChannel, case .group = channel.info, (channel.flags.contains(.isForum) || channel.flags.contains(.isMonoforum)) { let threadId = peerAndThreadId.threadId if var data = transaction.getMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { if readMaxId > data.maxIncomingReadId { @@ -4315,7 +4315,7 @@ func replayFinalState( } else { updatedOutgoingThreadReadStates[peerAndThreadId] = readMaxId } - if let channel = transaction.getPeer(peerAndThreadId.peerId) as? TelegramChannel, case .group = channel.info, channel.flags.contains(.isForum) { + if let channel = transaction.getPeer(peerAndThreadId.peerId) as? TelegramChannel, case .group = channel.info, (channel.flags.contains(.isForum) || channel.flags.contains(.isMonoforum)) { if var data = transaction.getMessageHistoryThreadInfo(peerId: peerAndThreadId.peerId, threadId: peerAndThreadId.threadId)?.data.get(MessageHistoryThreadData.self) { if readMaxId >= data.maxOutgoingReadId { data.maxOutgoingReadId = readMaxId diff --git a/submodules/TelegramCore/Sources/State/AccountStateManager.swift b/submodules/TelegramCore/Sources/State/AccountStateManager.swift index c81ec2048a..a4ec6abec5 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManager.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManager.swift @@ -2345,7 +2345,8 @@ public func messagesForNotification(transaction: Transaction, id: MessageId, alw var notificationSettingsStack: [TelegramPeerNotificationSettings] = [] - if let threadId = message.threadId, let threadData = transaction.getMessageHistoryThreadInfo(peerId: message.id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { + if let peer = peer as? TelegramChannel, peer.isMonoForum { + } else if let threadId = message.threadId, let threadData = transaction.getMessageHistoryThreadInfo(peerId: message.id.peerId, threadId: threadId)?.data.get(MessageHistoryThreadData.self) { notificationSettingsStack.append(threadData.notificationSettings) } diff --git a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift index 6521525367..27240c6ccd 100644 --- a/submodules/TelegramCore/Sources/State/PendingMessageManager.swift +++ b/submodules/TelegramCore/Sources/State/PendingMessageManager.swift @@ -949,7 +949,9 @@ public final class PendingMessageManager { var monoforumPeerId: Api.InputPeer? if let threadId = messages[0].0.threadId { if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { - monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } } else { flags |= Int32(1 << 9) topMsgId = Int32(clamping: threadId) @@ -1043,7 +1045,9 @@ public final class PendingMessageManager { var monoforumPeerId: Api.InputPeer? if let threadId = messages[0].0.threadId { if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { - monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } } else { flags |= Int32(1 << 9) topMsgId = Int32(clamping: threadId) @@ -1352,7 +1356,9 @@ public final class PendingMessageManager { var monoforumPeerId: Api.InputPeer? if let threadId = message.threadId { if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { - monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } } else { topMsgId = Int32(clamping: threadId) } @@ -1615,7 +1621,9 @@ public final class PendingMessageManager { var monoforumPeerId: Api.InputPeer? if let threadId = message.threadId { if let channel = peer as? TelegramChannel, channel.flags.contains(.isMonoforum) { - monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = transaction.getPeer(linkedMonoforumId) as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + monoforumPeerId = transaction.getPeer(PeerId(threadId)).flatMap(apiInputPeer) + } } else { flags |= Int32(1 << 9) topMsgId = Int32(clamping: threadId) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift index 5bc38f77ae..b0accb28cf 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift @@ -194,7 +194,7 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { }, automaticThreadIndexInfo: { peerId, _ in if peerId.namespace == Namespaces.Peer.CloudUser { - return StoredMessageHistoryThreadInfo(data: CodableEntry(data: Data()), summary: StoredMessageHistoryThreadInfo.Summary(totalUnreadCount: 0, isMarkedUnread: false, mutedUntil: nil)) + return StoredMessageHistoryThreadInfo(data: CodableEntry(data: Data()), summary: StoredMessageHistoryThreadInfo.Summary(totalUnreadCount: 0, isMarkedUnread: false, mutedUntil: nil, maxOutgoingReadId: 0)) } else { return nil } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift index 3999c200e2..2321c6c6dd 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Data/PeersData.swift @@ -204,6 +204,11 @@ public extension TelegramEngine.EngineData.Item { return nil } peers[mainPeer.id] = EnginePeer(mainPeer) + } else if let channel = peer as? TelegramChannel, channel.isMonoForum, let linkedMonoforumId = channel.linkedMonoforumId { + guard let mainChannel = view.peers[linkedMonoforumId] else { + return nil + } + peers[mainChannel.id] = EnginePeer(mainChannel) } return EngineRenderedPeer(peerId: self.id, peers: peers, associatedMedia: view.media) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift index 09ec98fd65..5874962f80 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/UpdatePinnedMessage.swift @@ -110,12 +110,18 @@ func _internal_requestUpdatePinnedMessage(account: Account, peerId: PeerId, upda } func _internal_requestUnpinAllMessages(account: Account, peerId: PeerId, threadId: Int64?) -> Signal { - return account.postbox.transaction { transaction -> (Peer?, CachedPeerData?) in - return (transaction.getPeer(peerId), transaction.getPeerCachedData(peerId: peerId)) + return account.postbox.transaction { transaction -> (Peer?, Peer?, CachedPeerData?) in + let peer = transaction.getPeer(peerId) + var subPeer: Peer? + if let channel = peer as? TelegramChannel, channel.isMonoForum, let threadId { + subPeer = transaction.getPeer(PeerId(threadId)) + } + + return (peer, subPeer, transaction.getPeerCachedData(peerId: peerId)) } |> mapError { _ -> UpdatePinnedMessageError in } - |> mapToSignal { peer, cachedPeerData -> Signal in + |> mapToSignal { peer, subPeer, cachedPeerData -> Signal in guard let peer = peer, let inputPeer = apiInputPeer(peer) else { return .fail(.generic) } @@ -148,10 +154,20 @@ func _internal_requestUnpinAllMessages(account: Account, peerId: PeerId, threadI } var flags: Int32 = 0 - if threadId != nil { - flags |= (1 << 0) + var topMsgId: Int32? + var savedPeerId: Api.InputPeer? + if let threadId { + if let channel = peer as? TelegramChannel, channel.isMonoForum { + if let inputSubPeer = subPeer.flatMap(apiInputPeer) { + flags |= (1 << 1) + savedPeerId = inputSubPeer + } + } else { + flags |= (1 << 0) + topMsgId = Int32(clamping: threadId) + } } - let request: Signal = account.network.request(Api.functions.messages.unpinAllMessages(flags: flags, peer: inputPeer, topMsgId: threadId.flatMap(Int32.init(clamping:)))) + let request: Signal = account.network.request(Api.functions.messages.unpinAllMessages(flags: flags, peer: inputPeer, topMsgId: topMsgId, savedPeerId: savedPeerId)) |> mapError { error -> InternalError in return .error(error.errorDescription) } diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index e9a4798453..bc58b58870 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -1262,21 +1262,28 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, let starsString = strings.Notification_PaidMessagePriceChanged_Stars(Int32(stars)) if message.author?.id == accountPeerId { let resultString: PresentationStrings.FormattedString - if broadcastMessagesAllowed { - resultString = strings.Notification_PaidMessagePriceChangedAndEnabledChannelMessageYou(starsString) - } else { - resultString = strings.Notification_PaidMessagePriceChangedYou(starsString) - } + resultString = strings.Notification_PaidMessagePriceChangedYou(starsString) attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: [0: boldAttributes]) } else { let peerName = message.author?.compactDisplayTitle ?? "" var attributes = peerMentionsAttributes(primaryTextColor: primaryTextColor, peerIds: [(0, message.author?.id)]) attributes[1] = boldAttributes let resultString: PresentationStrings.FormattedString + if broadcastMessagesAllowed { - resultString = strings.Notification_PaidMessagePriceChangedAndEnabledChannelMessage(peerName, starsString) + if stars == 0 { + resultString = strings.Notification_ChannelMessagePriceZeroChanged(peerName) + } else { + var rawString = strings.Notification_ChannelMessagePriceChanged(Int32(stars)) + rawString = rawString.replacingOccurrences(of: "{name}", with: peerName) + resultString = PresentationStrings.FormattedString(string: rawString, ranges: []) + } } else { - resultString = strings.Notification_PaidMessagePriceChanged(peerName, starsString) + if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info { + resultString = strings.Notification_ChannelMessageDisabled(peerName) + } else { + resultString = strings.Notification_PaidMessagePriceChanged(peerName, starsString) + } } attributedString = addAttributesToStringWithRanges(resultString._tuple, body: bodyAttributes, argumentAttributes: attributes) } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 2dbaa6367f..922a08f279 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -479,6 +479,8 @@ swift_library( "//submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen", "//submodules/TelegramUI/Components/ForumSettingsScreen", "//submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel", + "//submodules/TelegramUI/Components/GifVideoLayer", + "//submodules/TelegramUI/Components/BatchVideoRendering", ] + select({ "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, "//build-system:ios_sim_arm64": [], diff --git a/submodules/TelegramUI/Components/AsyncListComponent/BUILD b/submodules/TelegramUI/Components/AsyncListComponent/BUILD new file mode 100644 index 0000000000..51c0d2258b --- /dev/null +++ b/submodules/TelegramUI/Components/AsyncListComponent/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "AsyncListComponent", + module_name = "AsyncListComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/AsyncDisplayKit", + "//submodules/Display", + "//submodules/ComponentFlow", + "//submodules/SSignalKit/SwiftSignalKit", + "//submodules/MergeLists", + "//submodules/Components/ComponentDisplayAdapters", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift new file mode 100644 index 0000000000..ac981ff621 --- /dev/null +++ b/submodules/TelegramUI/Components/AsyncListComponent/Sources/AsyncListComponent.swift @@ -0,0 +1,603 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import ComponentFlow +import SwiftSignalKit +import MergeLists +import ComponentDisplayAdapters + +public final class AsyncListComponent: Component { + public protocol ItemView: UIView { + func isReorderable(at point: CGPoint) -> Bool + } + + public final class OverlayContainerView: UIView { + public override init(frame: CGRect) { + super.init(frame: frame) + + self.layer.anchorPoint = CGPoint() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func updatePosition(position: CGPoint, transition: ComponentTransition) { + let previousPosition: CGPoint + var forceUpdate = false + if self.layer.animation(forKey: "positionUpdate") != nil, let presentation = self.layer.presentation() { + forceUpdate = true + previousPosition = presentation.position + + if !transition.animation.isImmediate { + self.layer.removeAnimation(forKey: "positionUpdate") + } + } else { + previousPosition = self.layer.position + } + + if previousPosition != position || forceUpdate { + self.center = position + if case let .curve(duration, curve) = transition.animation { + self.layer.animate( + from: NSValue(cgPoint: CGPoint(x: previousPosition.x - position.x, y: previousPosition.y - position.y)), + to: NSValue(cgPoint: CGPoint()), + keyPath: "position", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: true, + completion: nil, + key: "positionUpdate" + ) + } + } + } + } + + final class ResetScrollingRequest: Equatable { + let requestId: Int + let id: AnyHashable + + init(requestId: Int, id: AnyHashable) { + self.requestId = requestId + self.id = id + } + + static func ==(lhs: ResetScrollingRequest, rhs: ResetScrollingRequest) -> Bool { + if lhs === rhs { + return true + } + if lhs.requestId != rhs.requestId { + return false + } + if lhs.id != rhs.id { + return false + } + return true + } + } + + public final class ExternalState { + public struct Value: Equatable { + var resetScrollingRequest: ResetScrollingRequest? + + public static func ==(lhs: Value, rhs: Value) -> Bool { + if lhs.resetScrollingRequest != rhs.resetScrollingRequest { + return false + } + return true + } + } + + public private(set) var value: Value = Value() + private var nextId: Int = 0 + + public init() { + + } + + public func resetScrolling(id: AnyHashable) { + let requestId = self.nextId + self.nextId += 1 + self.value.resetScrollingRequest = ResetScrollingRequest(requestId: requestId, id: id) + } + } + + public enum Direction { + case vertical + case horizontal + } + + public final class VisibleItem { + public let item: AnyComponentWithIdentity + public let frame: CGRect + + init(item: AnyComponentWithIdentity, frame: CGRect) { + self.item = item + self.frame = frame + } + } + + public final class VisibleItems: Sequence, IteratorProtocol { + private let view: AsyncListComponent.View + private var index: Int = 0 + private let indices: [(Int, CGRect)] + + init(view: AsyncListComponent.View, direction: Direction) { + self.view = view + var indices: [(Int, CGRect)] = [] + view.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListItemNodeImpl, let index = itemNode.index { + var itemFrame = itemNode.frame + itemFrame.origin.y -= itemNode.transitionOffset + if let animation = itemNode.animationForKey("height") { + if let height = animation.to as? CGFloat { + itemFrame.size.height = height + } + } + + if case .horizontal = direction { + itemFrame = CGRect(origin: CGPoint(x: itemFrame.minY, y: itemFrame.minX), size: CGSize(width: itemFrame.height, height: itemFrame.width)) + } + + indices.append((index, itemFrame)) + } + } + indices.sort(by: { $0.0 < $1.0 }) + self.indices = indices + } + + public func next() -> VisibleItem? { + if self.index >= self.indices.count { + return nil + } + let index = self.index + self.index += 1 + + if let component = self.view.component { + let (itemIndex, itemFrame) = self.indices[index] + return VisibleItem(item: component.items[itemIndex], frame: itemFrame) + } + + return nil + } + } + + public let externalState: ExternalState + public let externalStateValue: ExternalState.Value + public let items: [AnyComponentWithIdentity] + public let itemSetId: AnyHashable // Changing itemSetId supresses update animations + public let direction: Direction + public let insets: UIEdgeInsets + public let reorderItems: ((Int, Int) -> Bool)? + public let onVisibleItemsUpdated: ((VisibleItems, ComponentTransition) -> Void)? + + public init( + externalState: ExternalState, + items: [AnyComponentWithIdentity], + itemSetId: AnyHashable, + direction: Direction, + insets: UIEdgeInsets, + reorderItems: ((Int, Int) -> Bool)? = nil, + onVisibleItemsUpdated: ((VisibleItems, ComponentTransition) -> Void)? = nil + ) { + self.externalState = externalState + self.externalStateValue = externalState.value + self.items = items + self.itemSetId = itemSetId + self.direction = direction + self.insets = insets + self.reorderItems = reorderItems + self.onVisibleItemsUpdated = onVisibleItemsUpdated + } + + public static func ==(lhs: AsyncListComponent, rhs: AsyncListComponent) -> Bool { + if lhs.externalState !== rhs.externalState { + return false + } + if lhs.items != rhs.items { + return false + } + if lhs.itemSetId != rhs.itemSetId { + return false + } + if lhs.direction != rhs.direction { + return false + } + if lhs.insets != rhs.insets { + return false + } + if (lhs.reorderItems == nil) != (rhs.reorderItems == nil) { + return false + } + return true + } + + private struct ItemEntry: Comparable, Identifiable { + let contents: AnyComponentWithIdentity + let index: Int + + var id: AnyHashable { + return self.contents.id + } + + var stableId: AnyHashable { + return self.id + } + + static func ==(lhs: ItemEntry, rhs: ItemEntry) -> Bool { + if lhs.contents != rhs.contents { + return false + } + if lhs.index != rhs.index { + return false + } + return true + } + + static func <(lhs: ItemEntry, rhs: ItemEntry) -> Bool { + return lhs.index < rhs.index + } + + func item(parentView: AsyncListComponent.View?, direction: Direction) -> ListViewItem { + return ListItemImpl(parentView: parentView, contents: self.contents, direction: direction) + } + } + + private final class ListItemImpl: ListViewItem { + weak var parentView: AsyncListComponent.View? + let contents: AnyComponentWithIdentity + let direction: Direction + + let selectable: Bool = false + + init(parentView: AsyncListComponent.View?, contents: AnyComponentWithIdentity, direction: Direction) { + self.parentView = parentView + self.contents = contents + self.direction = direction + } + + func nodeConfiguredForParams( + async: @escaping (@escaping () -> Void) -> Void, + params: ListViewItemLayoutParams, + synchronousLoads: Bool, + previousItem: ListViewItem?, + nextItem: ListViewItem?, + completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void + ) { + async { + let impl: () -> Void = { + let node = ListItemNodeImpl() + let (nodeLayout, apply) = node.asyncLayout()(self, params) + node.insets = nodeLayout.insets + node.contentSize = nodeLayout.contentSize + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in + apply(false) + }) + }) + } + } + + if Thread.isMainThread { + impl() + } else { + assert(false) + Queue.mainQueue().async { + impl() + } + } + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + assert(node() is ListItemNodeImpl) + if let nodeValue = node() as? ListItemNodeImpl { + let layout = nodeValue.asyncLayout() + async { + let impl: () -> Void = { + let (nodeLayout, apply) = layout(self, params) + Queue.mainQueue().async { + completion(nodeLayout, { _ in + apply(animation.isAnimated) + }) + } + } + + if Thread.isMainThread { + impl() + } else { + assert(false) + Queue.mainQueue().async { + impl() + } + } + } + } + } + } + } + + private final class ListItemNodeImpl: ListViewItemNode { + private let contentsView = ComponentView() + private(set) var item: ListItemImpl? + + init() { + super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) + } + + deinit { + } + + override func isReorderable(at point: CGPoint) -> Bool { + if let itemView = self.contentsView.view as? ItemView { + return itemView.isReorderable(at: self.view.convert(point, to: itemView)) + } + return false + } + + override func snapshotForReordering() -> UIView? { + return self.view.snapshotView(afterScreenUpdates: false) + } + + func asyncLayout() -> (ListItemImpl, ListViewItemLayoutParams) -> (ListViewItemNodeLayout, (Bool) -> Void) { + return { item, params in + let containerSize: CGSize + switch item.direction { + case .vertical: + containerSize = CGSize(width: params.width, height: 100000.0) + case .horizontal: + containerSize = CGSize(width: 100000.0, height: params.width) + } + + let contentsSize = self.contentsView.update( + transition: .immediate, + component: item.contents.component, + environment: {}, + containerSize: containerSize + ) + + let mappedContentsSize: CGSize + switch item.direction { + case .vertical: + mappedContentsSize = CGSize(width: params.width, height: contentsSize.height) + case .horizontal: + mappedContentsSize = CGSize(width: params.width, height: contentsSize.width) + } + + let itemLayout = ListViewItemNodeLayout(contentSize: mappedContentsSize, insets: UIEdgeInsets()) + return (itemLayout, { animated in + self.item = item + + switch item.direction { + case .vertical: + self.layer.sublayerTransform = CATransform3DIdentity + case .horizontal: + self.layer.sublayerTransform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + } + + let contentsFrame = CGRect(origin: CGPoint(), size: contentsSize) + + if let contentsComponentView = self.contentsView.view { + if contentsComponentView.superview == nil { + self.view.addSubview(contentsComponentView) + } + contentsComponentView.center = CGPoint(x: mappedContentsSize.width * 0.5, y: mappedContentsSize.height * 0.5) + contentsComponentView.bounds = CGRect(origin: CGPoint(), size: contentsFrame.size) + } + }) + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { + super.animateInsertion(currentTimestamp, duration: duration, options: options) + + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + super.animateRemoved(currentTimestamp, duration: duration) + + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + override func animateAdded(_ currentTimestamp: Double, duration: Double) { + super.animateAdded(currentTimestamp, duration: duration) + + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + + public final class View: UIView { + let listNode: ListView + + private var externalStateValue: ExternalState.Value? + private var isUpdating: Bool = false + private(set) var component: AsyncListComponent? + + private var currentEntries: [ItemEntry] = [] + + private var ignoreUpdateVisibleItems: Bool = false + + public override init(frame: CGRect) { + self.listNode = ListView() + self.listNode.useMainQueueTransactions = true + self.listNode.scroller.delaysContentTouches = false + self.listNode.reorderedItemHasShadow = false + + super.init(frame: frame) + + self.addSubview(self.listNode.view) + + self.listNode.onContentsUpdated = { [weak self] transition in + guard let self else { + return + } + self.updateVisibleItems(transition: ComponentTransition(transition)) + } + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + return super.hitTest(point, with: event) + } + + public func stopScrolling() { + self.listNode.stopScrolling() + } + + private func updateVisibleItems(transition: ComponentTransition) { + if self.ignoreUpdateVisibleItems { + return + } + guard let component = self.component else { + return + } + if let onVisibleItemsUpdated = component.onVisibleItemsUpdated { + onVisibleItemsUpdated(VisibleItems(view: self, direction: component.direction), transition) + } + } + + func update(component: AsyncListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + + let listSize: CGSize + let listInsets: UIEdgeInsets + switch component.direction { + case .vertical: + self.listNode.transform = CATransform3DIdentity + listSize = CGSize(width: availableSize.width, height: availableSize.height) + listInsets = component.insets + case .horizontal: + self.listNode.transform = CATransform3DMakeRotation(-CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + listSize = CGSize(width: availableSize.height, height: availableSize.width) + listInsets = UIEdgeInsets(top: component.insets.left, left: component.insets.top, bottom: component.insets.right, right: component.insets.bottom) + } + + var updateSizeAndInsets = ListViewUpdateSizeAndInsets( + size: listSize, + insets: listInsets, + duration: 0.0, + curve: .Default(duration: nil) + ) + + var animateTransition = false + var transactionOptions: ListViewDeleteAndInsertOptions = [] + + if !transition.animation.isImmediate, let previousComponent { + if previousComponent.itemSetId == component.itemSetId { + transactionOptions.insert(.AnimateInsertion) + } + animateTransition = true + + switch transition.animation { + case .none: + break + case let .curve(duration, curve): + updateSizeAndInsets.duration = duration + switch curve { + case .linear, .easeInOut: + updateSizeAndInsets.curve = .Default(duration: duration) + case .spring: + updateSizeAndInsets.curve = .Spring(duration: duration) + case let .custom(a, b, c, d): + updateSizeAndInsets.curve = .Custom(duration: duration, a, b, c, d) + } + } + } + + var entries: [ItemEntry] = [] + for item in component.items { + entries.append(ItemEntry( + contents: item, + index: entries.count + )) + } + + var scrollToItem: ListViewScrollToItem? + if let resetScrollingRequest = component.externalStateValue.resetScrollingRequest, previousComponent?.externalStateValue.resetScrollingRequest != component.externalStateValue.resetScrollingRequest { + //TODO:release calculate direction hint + if let index = entries.firstIndex(where: { $0.id == resetScrollingRequest.id }) { + scrollToItem = ListViewScrollToItem( + index: index, + position: .visible, + animated: animateTransition, + curve: updateSizeAndInsets.curve, + directionHint: .Down + ) + } + } + + self.ignoreUpdateVisibleItems = true + + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: self.currentEntries, rightList: entries) + self.currentEntries = entries + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(parentView: self, direction: component.direction), directionHint: .Down) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(parentView: self, direction: component.direction), directionHint: nil) } + + transactionOptions.insert(.Synchronous) + + self.listNode.transaction( + deleteIndices: deletions, + insertIndicesAndItems: insertions, + updateIndicesAndItems: updates, + options: transactionOptions, + scrollToItem: scrollToItem, + updateSizeAndInsets: updateSizeAndInsets, + stationaryItemRange: nil, + updateOpaqueState: nil, + completion: { _ in } + ) + + let mappedListFrame: CGRect + switch component.direction { + case .vertical: + mappedListFrame = CGRect(origin: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5), size: listSize) + case .horizontal: + mappedListFrame = CGRect(origin: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5), size: listSize) + } + self.listNode.position = mappedListFrame.origin + self.listNode.bounds = CGRect(origin: CGPoint(), size: mappedListFrame.size) + + self.listNode.reorderItem = { [weak self] fromIndex, toIndex, _ in + guard let self, let component = self.component else { + return .single(false) + } + guard let reorderItems = component.reorderItems else { + return .single(false) + } + + if reorderItems(fromIndex, toIndex) { + return .single(true) + } else { + return .single(false) + } + } + + self.ignoreUpdateVisibleItems = false + + self.updateVisibleItems(transition: transition) + + return availableSize + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift b/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift index 7a82d4ddde..65d6f1858e 100644 --- a/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatChannelSubscriberInputPanelNode/Sources/ChatChannelSubscriberInputPanelNode.swift @@ -382,7 +382,7 @@ public final class ChatChannelSubscriberInputPanelNode: ChatInputPanelNode { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let _ = presentationData //TODO:localize - let text: String = "Tap here to suggest a message" + let text: String = "Tap here to send a message" let tooltipController = TooltipScreen( account: context.account, diff --git a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD index 436d275171..7dc886d4b5 100644 --- a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/BUILD @@ -19,7 +19,8 @@ swift_library( "//submodules/AppBundle", "//submodules/ContextUI", "//submodules/SoftwareVideo", - "//submodules/TelegramUI/Components/MultiplexedVideoNode", + "//submodules/TelegramUI/Components/BatchVideoRendering", + "//submodules/TelegramUI/Components/GifVideoLayer", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift index 5e55312bd9..0b75b02a83 100644 --- a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift +++ b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift @@ -10,17 +10,21 @@ import PhotoResources import AppBundle import ContextUI import SoftwareVideo -import MultiplexedVideoNode +import BatchVideoRendering +import GifVideoLayer +import AccountContext public final class ChatContextResultPeekContent: PeekControllerContent { - public let account: Account + public let context: AccountContext public let contextResult: ChatContextResult public let menu: [ContextMenuItem] + public let batchVideoContext: BatchVideoRenderingContext - public init(account: Account, contextResult: ChatContextResult, menu: [ContextMenuItem]) { - self.account = account + public init(context: AccountContext, contextResult: ChatContextResult, menu: [ContextMenuItem], batchVideoContext: BatchVideoRenderingContext) { + self.context = context self.contextResult = contextResult self.menu = menu + self.batchVideoContext = batchVideoContext } public func presentation() -> PeekControllerContentPresentation { @@ -36,7 +40,7 @@ public final class ChatContextResultPeekContent: PeekControllerContent { } public func node() -> PeekControllerContentNode & ASDisplayNode { - return ChatContextResultPeekNode(account: self.account, contextResult: self.contextResult) + return ChatContextResultPeekNode(context: self.context, contextResult: self.contextResult, batchVideoContext: self.batchVideoContext) } public func topAccessoryNode() -> ASDisplayNode? { @@ -62,62 +66,29 @@ public final class ChatContextResultPeekContent: PeekControllerContent { } private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerContentNode { - private let account: Account + private let context: AccountContext private let contextResult: ChatContextResult + private let batchVideoContext: BatchVideoRenderingContext private let imageNodeBackground: ASDisplayNode private let imageNode: TransformImageNode - private var videoLayer: (SoftwareVideoThumbnailNode, SoftwareVideoLayerFrameManager, SampleBufferLayer)? + private var videoLayer: GifVideoLayer? private var currentImageResource: TelegramMediaResource? private var currentVideoFile: TelegramMediaFile? - private let timebase: CMTimebase - - private var displayLink: CADisplayLink? private var ticking: Bool = false { didSet { if self.ticking != oldValue { - if self.ticking { - class DisplayLinkProxy: NSObject { - weak var target: ChatContextResultPeekNode? - init(target: ChatContextResultPeekNode) { - self.target = target - } - - @objc func displayLinkEvent() { - self.target?.displayLinkEvent() - } - } - - let displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) - self.displayLink = displayLink - displayLink.add(to: RunLoop.main, forMode: .common) - if #available(iOS 10.0, *) { - displayLink.preferredFramesPerSecond = 25 - } else { - displayLink.frameInterval = 2 - } - displayLink.isPaused = false - CMTimebaseSetRate(self.timebase, rate: 1.0) - } else if let displayLink = self.displayLink { - self.displayLink = nil - displayLink.isPaused = true - displayLink.invalidate() - CMTimebaseSetRate(self.timebase, rate: 0.0) - } + self.videoLayer?.shouldBeAnimating = self.ticking } } } - private func displayLinkEvent() { - let timestamp = CMTimebaseGetTime(self.timebase).seconds - self.videoLayer?.1.tick(timestamp: timestamp) - } - - init(account: Account, contextResult: ChatContextResult) { - self.account = account + init(context: AccountContext, contextResult: ChatContextResult, batchVideoContext: BatchVideoRenderingContext) { + self.context = context self.contextResult = contextResult + self.batchVideoContext = batchVideoContext self.imageNodeBackground = ASDisplayNode() self.imageNodeBackground.isLayerBacked = true @@ -128,11 +99,6 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont self.imageNode.isLayerBacked = !smartInvertColorsEnabled() self.imageNode.displaysAsynchronously = false - var timebase: CMTimebase? - CMTimebaseCreateWithSourceClock(allocator: nil, sourceClock: CMClockGetHostTimeClock(), timebaseOut: &timebase) - CMTimebaseSetRate(timebase!, rate: 0.0) - self.timebase = timebase! - super.init() self.addSubnode(self.imageNodeBackground) @@ -142,10 +108,6 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont } deinit { - if let displayLink = self.displayLink { - displayLink.isPaused = true - displayLink.invalidate() - } } func ready() -> Signal { @@ -236,7 +198,7 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont if let imageResource = imageResource { let tmpRepresentation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: Int32(fittedImageDimensions.width * 2.0), height: Int32(fittedImageDimensions.height * 2.0)), resource: imageResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) let tmpImage = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [tmpRepresentation], immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: []) - updateImageSignal = chatMessagePhoto(postbox: self.account.postbox, userLocation: .other, photoReference: .standalone(media: tmpImage)) + updateImageSignal = chatMessagePhoto(postbox: self.context.account.postbox, userLocation: .other, photoReference: .standalone(media: tmpImage)) } else { updateImageSignal = .complete() } @@ -256,33 +218,26 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont } if updatedVideoFile { - if let (thumbnailLayer, _, layer) = self.videoLayer { + if let videoLayer = self.videoLayer { self.videoLayer = nil - thumbnailLayer.removeFromSupernode() - layer.layer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() } - if let videoFileReference = videoFileReference { - let thumbnailLayer = SoftwareVideoThumbnailNode(account: self.account, fileReference: videoFileReference, synchronousLoad: false) - self.addSubnode(thumbnailLayer) - let layerHolder = takeSampleBufferLayer() - layerHolder.layer.videoGravity = AVLayerVideoGravity.resizeAspectFill - self.layer.addSublayer(layerHolder.layer) - let manager = SoftwareVideoLayerFrameManager(account: self.account, userLocation: .other, userContentType: .other, fileReference: videoFileReference, layerHolder: layerHolder) - self.videoLayer = (thumbnailLayer, manager, layerHolder) - thumbnailLayer.ready = { [weak self, weak thumbnailLayer, weak manager] in - if let strongSelf = self, let thumbnailLayer = thumbnailLayer, let manager = manager { - if strongSelf.videoLayer?.0 === thumbnailLayer && strongSelf.videoLayer?.1 === manager { - manager.start() - } - } - } + if let videoFileReference { + let videoLayer = GifVideoLayer( + context: self.context, + batchVideoContext: self.batchVideoContext, + userLocation: .other, + file: videoFileReference, + synchronousLoad: false + ) + self.videoLayer = videoLayer + self.layer.addSublayer(videoLayer) } } - if let (thumbnailLayer, _, layer) = self.videoLayer { - thumbnailLayer.frame = CGRect(origin: CGPoint(), size: croppedImageDimensions) - layer.layer.frame = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) + if let videoLayer = self.videoLayer { + videoLayer.frame = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) } if !self.ticking { diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift index 635239cca1..163be10337 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageBubbleItemNode/Sources/ChatMessageBubbleItemNode.swift @@ -1597,7 +1597,7 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI } } if isMonoForum { - if case .replyThread = item.chatLocation { + if case let .replyThread(replyThreadMessage) = item.chatLocation, firstMessage.effectivelyIncoming(item.context.account.peerId), item.effectiveAuthorId == PeerId(replyThreadMessage.threadId) { displayAuthorInfo = false } } diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD index 0a13c0f284..faaa994060 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/BUILD @@ -24,6 +24,13 @@ swift_library( "//submodules/TelegramUI/Components/EmojiStatusComponent", "//submodules/Components/BundleIconComponent", "//submodules/AvatarNode", + "//submodules/ChatListUI", + "//submodules/ContextUI", + "//submodules/TelegramUI/Components/AsyncListComponent", + "//submodules/TelegramUI/Components/TextBadgeComponent", + "//submodules/TelegramUI/Components/MaskedContainerComponent", + "//submodules/AppBundle", + "//submodules/PresentationDataUtils", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift index b5b43da910..9d5ff46ff3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSideTopicsPanel/Sources/ChatSideTopicsPanel.swift @@ -14,6 +14,13 @@ import BlurredBackgroundComponent import EmojiStatusComponent import BundleIconComponent import AvatarNode +import ChatListUI +import ContextUI +import AsyncListComponent +import TextBadgeComponent +import MaskedContainerComponent +import AppBundle +import PresentationDataUtils public final class ChatSidePanelEnvironment: Equatable { public let insets: UIEdgeInsets @@ -33,12 +40,19 @@ public final class ChatSidePanelEnvironment: Equatable { public final class ChatSideTopicsPanel: Component { public typealias EnvironmentType = ChatSidePanelEnvironment + public enum Location { + case side + case top + } + let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings + let location: Location let peerId: EnginePeer.Id let isMonoforum: Bool let topicId: Int64? + let controller: () -> ViewController? let togglePanel: () -> Void let updateTopicId: (Int64?, Bool) -> Void @@ -46,18 +60,22 @@ public final class ChatSideTopicsPanel: Component { context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, + location: Location, peerId: EnginePeer.Id, isMonoforum: Bool, topicId: Int64?, + controller: @escaping () -> ViewController?, togglePanel: @escaping () -> Void, updateTopicId: @escaping (Int64?, Bool) -> Void ) { self.context = context self.theme = theme self.strings = strings + self.location = location self.peerId = peerId self.isMonoforum = isMonoforum self.topicId = topicId + self.controller = controller self.togglePanel = togglePanel self.updateTopicId = updateTopicId } @@ -72,6 +90,9 @@ public final class ChatSideTopicsPanel: Component { if lhs.strings !== rhs.strings { return false } + if lhs.location != rhs.location { + return false + } if lhs.peerId != rhs.peerId { return false } @@ -108,223 +129,869 @@ public final class ChatSideTopicsPanel: Component { } } - private final class ItemView: UIView { - private let context: AccountContext - private let action: () -> Void + private protocol ItemComponent: AnyObject { + var item: Item { get } + } + + private final class VerticalItemComponent: Component, ItemComponent { + let context: AccountContext + let item: Item + let isSelected: Bool + let isReordering: Bool + let theme: PresentationTheme + let action: (() -> Void)? + let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? - private let extractedContainerNode: ContextExtractedContentContainingNode - private let containerNode: ContextControllerSourceNode - - private let containerButton: HighlightTrackingButton - - private var icon: ComponentView? - private var avatarNode: AvatarNode? - private let title = ComponentView() - - init(context: AccountContext, action: @escaping (() -> Void), contextGesture: @escaping (ContextGesture, ContextExtractedContentContainingNode) -> Void) { + init(context: AccountContext, item: Item, isSelected: Bool, isReordering: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?, contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?) { self.context = context + self.item = item + self.isSelected = isSelected + self.isReordering = isReordering + self.theme = theme self.action = action + self.contextGesture = contextGesture + } + + static func ==(lhs: VerticalItemComponent, rhs: VerticalItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.context !== rhs.context { + return false + } + if lhs.item != rhs.item { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.isReordering != rhs.isReordering { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + if (lhs.contextGesture == nil) != (rhs.contextGesture == nil) { + return false + } + return true + } + + final class View: UIView, AsyncListComponent.ItemView { + private let extractedContainerNode: ContextExtractedContentContainingNode + private let containerNode: ContextControllerSourceNode - self.extractedContainerNode = ContextExtractedContentContainingNode() - self.containerNode = ContextControllerSourceNode() + private let containerButton: UIView + private var extractedBackgroundView: UIImageView? - self.containerButton = HighlightTrackingButton() + private var tapRecognizer: UITapGestureRecognizer? - super.init(frame: CGRect()) + private let iconContainer: MaskedContainerView + private var icon: ComponentView? + private var avatarNode: AvatarNode? + private let title = ComponentView() + private var badge: ComponentView? - self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + private var component: VerticalItemComponent? - self.containerNode.addSubnode(self.extractedContainerNode) - self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode - self.addSubview(self.containerNode.view) - - self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - self.containerButton.highligthedChanged = { [weak self] highlighted in - if let self, self.bounds.width > 0.0 { - let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width - let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width + override init(frame: CGRect) { + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + + self.iconContainer = MaskedContainerView() + self.iconContainer.isUserInteractionEnabled = false + + self.containerButton = UIView() + + super.init(frame: frame) + + self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + self.containerButton.addSubview(self.iconContainer) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let component = self.component else { + return + } - if highlighted { - self.layer.removeAnimation(forKey: "opacity") - self.layer.removeAnimation(forKey: "sublayerTransform") - let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) - transition.updateTransformScale(layer: self.layer, scale: topScale) - } else { - let transition: ContainedViewLayoutTransition = .immediate - transition.updateTransformScale(layer: self.layer, scale: 1.0) - - self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in - guard let self else { - return - } - - self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) + if isExtracted { + let extractedBackgroundView: UIImageView + if let current = self.extractedBackgroundView { + extractedBackgroundView = current + } else { + extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: 28.0, color: component.theme.contextMenu.backgroundColor)) + self.extractedBackgroundView = extractedBackgroundView + self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) + extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 2.0, dy: 0.0) + extractedBackgroundView.alpha = 0.0 + } + transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) + } else if let extractedBackgroundView = self.extractedBackgroundView { + self.extractedBackgroundView = nil + let alphaTransition: ContainedViewLayoutTransition + if transition.isAnimated { + alphaTransition = .animated(duration: 0.18, curve: .easeInOut) + } else { + alphaTransition = .immediate + } + alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in + extractedBackgroundView?.removeFromSuperview() }) } } + + self.containerNode.isGestureEnabled = false } - self.containerNode.isGestureEnabled = false - } - - required init?(coder: NSCoder) { - preconditionFailure() - } - - @objc private func pressed() { - self.action() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center + required init?(coder: NSCoder) { + preconditionFailure() } - return super.hitTest(mappedPoint, with: event) - } - - func update(context: AccountContext, item: Item, isSelected: Bool, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { - let spacing: CGFloat = 3.0 - let iconSize = CGSize(width: 30.0, height: 30.0) - var avatarIconContent: EmojiStatusComponent.Content? - if case let .forum(topicId) = item.item.id { - if topicId != 1, let threadData = item.item.threadData { - if let fileId = threadData.info.icon, fileId != 0 { - avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .count(0)) - } else { - avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) - } - } else { - avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(theme), tintColor: theme.rootController.navigationBar.secondaryTextColor) + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() } } - if let avatarIconContent { - let avatarIconComponent = EmojiStatusComponent( - context: context, - animationCache: context.animationCache, - animationRenderer: context.animationRenderer, - content: avatarIconContent, - isVisibleForAnimations: false, - action: nil - ) - let icon: ComponentView - if let current = self.icon { - icon = current - } else { - icon = ComponentView() - self.icon = icon + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center } - let _ = icon.update( - transition: .immediate, - component: AnyComponent(avatarIconComponent), - environment: {}, - containerSize: iconSize - ) - } else if let icon = self.icon { - self.icon = nil - icon.view?.removeFromSuperview() + return super.hitTest(mappedPoint, with: event) } - let titleText: String - if case let .forum(topicId) = item.item.id { - let _ = topicId - if let threadData = item.item.threadData { - titleText = threadData.info.title - } else { - //TODO:localize - titleText = "General" + func isReorderable(at point: CGPoint) -> Bool { + guard let component = self.component else { + return false } - } else { - titleText = item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + return component.isReordering } - if let avatarIconContent, let icon = self.icon { - let avatarIconComponent = EmojiStatusComponent( - context: context, - animationCache: context.animationCache, - animationRenderer: context.animationRenderer, - content: avatarIconContent, - isVisibleForAnimations: false, - action: nil - ) - let _ = icon.update( - transition: .immediate, - component: AnyComponent(avatarIconComponent), - environment: {}, - containerSize: iconSize - ) - } - - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor)), - horizontalAlignment: .center, - maximumNumberOfLines: 2 - )), - environment: {}, - containerSize: CGSize(width: width - 6.0 * 2.0, height: 100.0) - ) - - let contentSize: CGFloat = iconSize.height + spacing + titleSize.height - let size = CGSize(width: width, height: contentSize) - - let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: 0.0), size: iconSize) - let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) - - if let icon = self.icon { - if let avatarNode = self.avatarNode { - self.avatarNode = nil - avatarNode.view.removeFromSuperview() + private func updateIsShaking(animated: Bool) { + guard let component = self.component else { + return } - if let iconView = icon.view { - if iconView.superview == nil { - iconView.isUserInteractionEnabled = false - self.containerButton.addSubview(iconView) + if component.isReordering { + if self.layer.animation(forKey: "shaking_position") == nil { + let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.layer.add(position, forKey: "shaking_position") + self.layer.add(transform, forKey: "shaking_rotation") } - iconView.frame = iconFrame + } else if self.layer.animation(forKey: "shaking_position") != nil { + if let presentationLayer = self.layer.presentation() { + let transition: ComponentTransition = .easeInOut(duration: 0.1) + if presentationLayer.position != self.layer.position { + transition.animatePosition(layer: self.layer, from: CGPoint(x: presentationLayer.position.x - self.layer.position.x, y: presentationLayer.position.y - self.layer.position.y), to: CGPoint(), additive: true) + } + if !CATransform3DIsIdentity(presentationLayer.transform) { + transition.setTransform(layer: self.layer, transform: CATransform3DIdentity) + } + } + + self.layer.removeAnimation(forKey: "shaking_position") + self.layer.removeAnimation(forKey: "shaking_rotation") } - } else { - let avatarNode: AvatarNode - if let current = self.avatarNode { - avatarNode = current - } else { - avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) - avatarNode.isUserInteractionEnabled = false - self.avatarNode = avatarNode - self.containerButton.addSubview(avatarNode.view) - } - avatarNode.frame = iconFrame - avatarNode.updateSize(size: iconFrame.size) + } + + func update(component: VerticalItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component - if let peer = item.item.renderedPeer.chatMainPeer { - if peer.smallProfileImage != nil { - avatarNode.setPeerV2(context: context, theme: theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + self.tapRecognizer?.isEnabled = component.action != nil + + self.containerNode.isGestureEnabled = component.contextGesture != nil + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + let topInset: CGFloat = 8.0 + let bottomInset: CGFloat = 8.0 + let spacing: CGFloat = 3.0 + let iconSize = CGSize(width: 30.0, height: 30.0) + + var avatarIconContent: EmojiStatusComponent.Content? + if case let .forum(topicId) = component.item.item.id { + if topicId != 1, let threadData = component.item.item.threadData { + if let fileId = threadData.info.icon, fileId != 0 { + avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(0)) + } else { + avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) + } } else { - avatarNode.setPeer(context: context, theme: theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(component.theme), tintColor: component.theme.rootController.navigationBar.secondaryTextColor) } } - } - - if let titleView = self.title.view { - if titleView.superview == nil { - titleView.isUserInteractionEnabled = false - self.containerButton.addSubview(titleView) + + if let avatarIconContent { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let icon: ComponentView + if let current = self.icon { + icon = current + } else { + icon = ComponentView() + self.icon = icon + } + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } else if let icon = self.icon { + self.icon = nil + icon.view?.removeFromSuperview() } - titleView.frame = titleFrame + + let titleText: String + if case let .forum(topicId) = component.item.item.id { + let _ = topicId + if let threadData = component.item.item.threadData { + titleText = threadData.info.title + } else { + //TODO:localize + titleText = "General" + } + } else { + titleText = component.item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + } + + if let avatarIconContent, let icon = self.icon { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0, height: 100.0) + ) + + let contentSize: CGFloat = topInset + bottomInset + iconSize.height + spacing + titleSize.height + let size = CGSize(width: availableSize.width, height: contentSize) + + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: topInset), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) + + self.iconContainer.frame = iconFrame + + if let icon = self.icon { + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.view.removeFromSuperview() + } + + if let iconView = icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.iconContainer.contentView.addSubview(iconView) + } + iconView.frame = CGRect(origin: CGPoint(), size: iconFrame.size) + } + } else { + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.iconContainer.contentView.addSubview(avatarNode.view) + } + avatarNode.frame = CGRect(origin: CGPoint(), size: iconFrame.size) + avatarNode.updateSize(size: iconFrame.size) + + if let peer = component.item.item.renderedPeer.chatMainPeer { + if peer.smallProfileImage != nil { + avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } + } + } + + var iconMaskItems: [MaskedContainerView.Item] = [] + if let readCounters = component.item.item.readCounters, readCounters.count > 0 { + let badge: ComponentView + var badgeTransition = transition + if let current = self.badge { + badge = current + } else { + badgeTransition = .immediate + badge = ComponentView() + self.badge = badge + } + + let badgeSize = badge.update( + transition: badgeTransition, + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), + font: Font.medium(12.0), + background: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveBackgroundColor : component.theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveTextColor : component.theme.chatList.unreadBadgeActiveTextColor, + insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + let badgeFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + 10.0 - badgeSize.width, y: iconFrame.minY - 6.0), size: badgeSize) + if let badgeView = badge.view { + if badgeView.superview == nil { + self.containerButton.addSubview(badgeView) + } + badgeView.frame = badgeFrame + } + let badgeMaskFrame = badgeFrame.offsetBy(dx: -iconFrame.minX, dy: -iconFrame.minY).insetBy(dx: -1.33, dy: -1.33) + iconMaskItems.append(MaskedContainerView.Item( + frame: badgeMaskFrame, + shape: .roundedRect(cornerRadius: badgeMaskFrame.height * 0.5) + )) + } else if let badge = self.badge { + self.badge = nil + badge.view?.removeFromSuperview() + } + self.iconContainer.update(size: iconFrame.size, items: iconMaskItems, isInverted: true) + self.iconContainer.frame = iconFrame + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + + self.updateIsShaking(animated: !transition.animation.isImmediate) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } + } + + private final class HorizontalItemComponent: Component, ItemComponent { + let context: AccountContext + let item: Item + let isSelected: Bool + let isReordering: Bool + let theme: PresentationTheme + let action: (() -> Void)? + let contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? + + init(context: AccountContext, item: Item, isSelected: Bool, isReordering: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?, contextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)?) { + self.context = context + self.item = item + self.isSelected = isSelected + self.isReordering = isReordering + self.theme = theme + self.action = action + self.contextGesture = contextGesture + } + + static func ==(lhs: HorizontalItemComponent, rhs: HorizontalItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.context !== rhs.context { + return false + } + if lhs.item != rhs.item { + return false + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.isReordering != rhs.isReordering { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + if (lhs.contextGesture == nil) != (rhs.contextGesture == nil) { + return false + } + return true + } + + final class View: UIView, AsyncListComponent.ItemView { + private let extractedContainerNode: ContextExtractedContentContainingNode + private let containerNode: ContextControllerSourceNode + + private let containerButton: UIView + private var extractedBackgroundView: UIImageView? + + private var tapRecognizer: UITapGestureRecognizer? + + private var icon: ComponentView? + private var avatarNode: AvatarNode? + private let title = ComponentView() + private var badge: ComponentView? + + private var component: HorizontalItemComponent? + + override init(frame: CGRect) { + self.extractedContainerNode = ContextExtractedContentContainingNode() + self.containerNode = ContextControllerSourceNode() + + self.containerButton = UIView() + + super.init(frame: frame) + + self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) + + self.containerNode.addSubnode(self.extractedContainerNode) + self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode + self.addSubview(self.containerNode.view) + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in + guard let self, let component = self.component else { + return + } + + if isExtracted { + let extractedBackgroundView: UIImageView + if let current = self.extractedBackgroundView { + extractedBackgroundView = current + } else { + extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: 28.0, color: component.theme.contextMenu.backgroundColor)) + self.extractedBackgroundView = extractedBackgroundView + self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) + extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 2.0, dy: 0.0) + extractedBackgroundView.alpha = 0.0 + } + transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) + } else if let extractedBackgroundView = self.extractedBackgroundView { + self.extractedBackgroundView = nil + let alphaTransition: ContainedViewLayoutTransition + if transition.isAnimated { + alphaTransition = .animated(duration: 0.18, curve: .easeInOut) + } else { + alphaTransition = .immediate + } + alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in + extractedBackgroundView?.removeFromSuperview() + }) + } + } + + self.containerNode.isGestureEnabled = false } - transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + required init?(coder: NSCoder) { + preconditionFailure() + } - self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) - self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } - return size + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func isReorderable(at point: CGPoint) -> Bool { + guard let component = self.component else { + return false + } + return component.isReordering + } + + private func updateIsShaking(animated: Bool) { + guard let component = self.component else { + return + } + + if component.isReordering { + if self.layer.animation(forKey: "shaking_position") == nil { + let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in + return .pi * x / 180.0 + } + + let duration: Double = 0.4 + let displacement: CGFloat = 1.0 + let degreesRotation: CGFloat = 2.0 + + let negativeDisplacement = -1.0 * displacement + let position = CAKeyframeAnimation.init(keyPath: "position") + position.beginTime = 0.8 + position.duration = duration + position.values = [ + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: 0, y: 0)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), + NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), + NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) + ] + position.calculationMode = .linear + position.isRemovedOnCompletion = false + position.repeatCount = Float.greatestFiniteMagnitude + position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + position.isAdditive = true + + let transform = CAKeyframeAnimation.init(keyPath: "transform") + transform.beginTime = 2.6 + transform.duration = 0.3 + transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) + transform.values = [ + degreesToRadians(-1.0 * degreesRotation), + degreesToRadians(degreesRotation), + degreesToRadians(-1.0 * degreesRotation) + ] + transform.calculationMode = .linear + transform.isRemovedOnCompletion = false + transform.repeatCount = Float.greatestFiniteMagnitude + transform.isAdditive = true + transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) + + self.layer.add(position, forKey: "shaking_position") + self.layer.add(transform, forKey: "shaking_rotation") + } + } else if self.layer.animation(forKey: "shaking_position") != nil { + if let presentationLayer = self.layer.presentation() { + let transition: ComponentTransition = .easeInOut(duration: 0.1) + if presentationLayer.position != self.layer.position { + transition.animatePosition(layer: self.layer, from: CGPoint(x: presentationLayer.position.x - self.layer.position.x, y: presentationLayer.position.y - self.layer.position.y), to: CGPoint(), additive: true) + } + if !CATransform3DIsIdentity(presentationLayer.transform) { + transition.setTransform(layer: self.layer, transform: CATransform3DIdentity) + } + } + + self.layer.removeAnimation(forKey: "shaking_position") + self.layer.removeAnimation(forKey: "shaking_rotation") + } + } + + func update(component: HorizontalItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + self.containerNode.isGestureEnabled = component.contextGesture != nil + self.containerNode.activated = { [weak self] gesture, _ in + guard let self, let component = self.component else { + return + } + component.contextGesture?(gesture, self.extractedContainerNode) + } + + let leftInset: CGFloat = 12.0 + let rightInset: CGFloat = 12.0 + let spacing: CGFloat = 4.0 + let badgeSpacing: CGFloat = 4.0 + let iconSize = CGSize(width: 18.0, height: 18.0) + + var avatarIconContent: EmojiStatusComponent.Content? + if case let .forum(topicId) = component.item.item.id { + if topicId != 1, let threadData = component.item.item.threadData { + if let fileId = threadData.info.icon, fileId != 0 { + avatarIconContent = .animation(content: .customEmoji(fileId: fileId), size: iconSize, placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(0)) + } else { + avatarIconContent = .topic(title: String(threadData.info.title.prefix(1)), color: threadData.info.iconColor, size: iconSize) + } + } else { + avatarIconContent = .image(image: PresentationResourcesChatList.generalTopicIcon(component.theme), tintColor: component.theme.rootController.navigationBar.secondaryTextColor) + } + } + + if let avatarIconContent { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let icon: ComponentView + if let current = self.icon { + icon = current + } else { + icon = ComponentView() + self.icon = icon + } + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } else if let icon = self.icon { + self.icon = nil + icon.view?.removeFromSuperview() + } + + let titleText: String + if case let .forum(topicId) = component.item.item.id { + let _ = topicId + if let threadData = component.item.item.threadData { + titleText = threadData.info.title + } else { + //TODO:localize + titleText = "General" + } + } else { + titleText = component.item.item.renderedPeer.chatMainPeer?.compactDisplayTitle ?? " " + } + + if let avatarIconContent, let icon = self.icon { + let avatarIconComponent = EmojiStatusComponent( + context: component.context, + animationCache: component.context.animationCache, + animationRenderer: component.context.animationRenderer, + content: avatarIconContent, + isVisibleForAnimations: false, + action: nil + ) + let _ = icon.update( + transition: .immediate, + component: AnyComponent(avatarIconComponent), + environment: {}, + containerSize: iconSize + ) + } + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.medium(14.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + horizontalAlignment: .center, + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0, height: 100.0) + ) + + var badgeSize: CGSize? + if let readCounters = component.item.item.readCounters, readCounters.count > 0 { + let badge: ComponentView + var badgeTransition = transition + if let current = self.badge { + badge = current + } else { + badgeTransition = .immediate + badge = ComponentView() + self.badge = badge + } + + badgeSize = badge.update( + transition: badgeTransition, + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), + font: Font.medium(12.0), + background: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveBackgroundColor : component.theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: component.item.item.isMuted ? component.theme.chatList.unreadBadgeInactiveTextColor : component.theme.chatList.unreadBadgeActiveTextColor, + insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + } else if let badge = self.badge { + self.badge = nil + badge.view?.removeFromSuperview() + } + + var contentSize: CGFloat = leftInset + rightInset + iconSize.width + spacing + titleSize.width + if let badgeSize { + contentSize += badgeSize.width + badgeSpacing + } + let size = CGSize(width: contentSize, height: availableSize.height) + + let iconFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - iconSize.height) * 0.5)), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: iconFrame.maxX + spacing, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + + if let icon = self.icon { + if let avatarNode = self.avatarNode { + self.avatarNode = nil + avatarNode.view.removeFromSuperview() + } + + if let iconView = icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.containerButton.addSubview(iconView) + } + iconView.frame = iconFrame + } + } else { + let avatarNode: AvatarNode + if let current = self.avatarNode { + avatarNode = current + } else { + avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 11.0)) + avatarNode.isUserInteractionEnabled = false + self.avatarNode = avatarNode + self.containerButton.addSubview(avatarNode.view) + } + avatarNode.frame = iconFrame + avatarNode.updateSize(size: iconFrame.size) + + if let peer = component.item.item.renderedPeer.chatMainPeer { + if peer.smallProfileImage != nil { + avatarNode.setPeerV2(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } else { + avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, overrideImage: nil, emptyColor: .gray, clipStyle: .round, synchronousLoad: false, displayDimensions: iconFrame.size) + } + } + } + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + if let badge = self.badge, let badgeSize { + let badgeFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + badgeSpacing, y: floor((size.height - badgeSize.height) * 0.5)), size: badgeSize) + if let badgeView = badge.view { + if badgeView.superview == nil { + self.containerButton.addSubview(badgeView) + } + badgeView.frame = badgeFrame + } + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) + self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + self.containerNode.frame = CGRect(origin: CGPoint(), size: size) + + self.updateIsShaking(animated: !transition.animation.isImmediate) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } @@ -337,7 +1004,9 @@ public final class ChatSideTopicsPanel: Component { private let containerButton: HighlightTrackingButton - private let icon = ComponentView() + private var icon = ComponentView() + + private var isReordering: Bool = false init(context: AccountContext, action: @escaping (() -> Void)) { self.context = context @@ -394,20 +1063,31 @@ public final class ChatSideTopicsPanel: Component { } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center - } - return super.hitTest(mappedPoint, with: event) + return super.hitTest(point, with: event) } - func update(context: AccountContext, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { + func update(context: AccountContext, theme: PresentationTheme, width: CGFloat, location: Location, isReordering: Bool, transition: ComponentTransition) -> CGSize { + let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2) + + var animateIconIn = false + if self.isReordering != isReordering { + self.isReordering = isReordering + if let iconView = self.icon.view { + self.icon = ComponentView() + transition.setScale(view: iconView, scale: 0.001) + alphaTransition.setAlpha(view: iconView, alpha: 0.0, completion: { [weak iconView] _ in + iconView?.removeFromSuperview() + }) + animateIconIn = true + } + } + let iconSize = self.icon.update( transition: .immediate, component: AnyComponent(BundleIconComponent( - name: "Chat/Title Panels/SidebarIcon", - tintColor: theme.rootController.navigationBar.accentTextColor, - maxSize: nil, + name: isReordering ? "Media Editor/Done" : "Chat/Title Panels/SidebarIcon", + tintColor: location == .side ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor, + maxSize: CGSize(width: 24.0, height: 24.0), scaleFactor: 1.0 )), environment: {}, @@ -415,7 +1095,7 @@ public final class ChatSideTopicsPanel: Component { ) let topInset: CGFloat = 10.0 - let bottomInset: CGFloat = 12.0 + let bottomInset: CGFloat = 2.0 let contentSize: CGFloat = topInset + iconSize.height + bottomInset let size = CGSize(width: width, height: contentSize) @@ -428,6 +1108,10 @@ public final class ChatSideTopicsPanel: Component { self.containerButton.addSubview(iconView) } iconView.frame = iconFrame + if animateIconIn { + alphaTransition.animateAlpha(view: iconView, from: 0.0, to: 1.0) + transition.animateScale(view: iconView, from: 0.001, to: 1.0) + } } transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) @@ -441,206 +1125,330 @@ public final class ChatSideTopicsPanel: Component { } } - private final class AllItemView: UIView { - private let context: AccountContext - private let action: () -> Void + private protocol AllItemComponent: AnyObject { + } + + private final class VerticalAllItemComponent: Component, AllItemComponent { + let isSelected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let action: (() -> Void)? - private let extractedContainerNode: ContextExtractedContentContainingNode - private let containerNode: ContextControllerSourceNode - - private let containerButton: HighlightTrackingButton - - private let icon = ComponentView() - private let title = ComponentView() - - init(context: AccountContext, action: @escaping (() -> Void)) { - self.context = context + init(isSelected: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?) { + self.isSelected = isSelected + self.theme = theme + self.strings = strings self.action = action - - self.extractedContainerNode = ContextExtractedContentContainingNode() - self.containerNode = ContextControllerSourceNode() - - self.containerButton = HighlightTrackingButton() - - super.init(frame: CGRect()) - - self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) - - self.containerNode.addSubnode(self.extractedContainerNode) - self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode - self.addSubview(self.containerNode.view) - - self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside) - self.containerButton.highligthedChanged = { [weak self] highlighted in - if let self, self.bounds.width > 0.0 { - let topScale: CGFloat = (self.bounds.width - 1.0) / self.bounds.width - let maxScale: CGFloat = (self.bounds.width + 1.0) / self.bounds.width - - if highlighted { - self.layer.removeAnimation(forKey: "opacity") - self.layer.removeAnimation(forKey: "sublayerTransform") - let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .easeInOut) - transition.updateTransformScale(layer: self.layer, scale: topScale) - } else { - let transition: ContainedViewLayoutTransition = .immediate - transition.updateTransformScale(layer: self.layer, scale: 1.0) - - self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in - guard let self else { - return - } - - self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue) - }) - } - } + } + + static func ==(lhs: VerticalAllItemComponent, rhs: VerticalAllItemComponent) -> Bool { + if lhs === rhs { + return true } - - self.containerNode.isGestureEnabled = false - } - - required init?(coder: NSCoder) { - preconditionFailure() - } - - @objc private func pressed() { - self.action() - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - var mappedPoint = point - if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { - mappedPoint = self.bounds.center + if lhs.isSelected != rhs.isSelected { + return false } - return super.hitTest(mappedPoint, with: event) - } - - func update(context: AccountContext, isSelected: Bool, theme: PresentationTheme, width: CGFloat, transition: ComponentTransition) -> CGSize { - let spacing: CGFloat = 3.0 - - let iconSize = self.icon.update( - transition: .immediate, - component: AnyComponent(BundleIconComponent( - name: "Chat List/Tabs/IconChats", - tintColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor - )), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - - //TODO:localize - let titleText: String = "All" - let titleSize = self.title.update( - transition: .immediate, - component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: isSelected ? theme.rootController.navigationBar.accentTextColor : theme.rootController.navigationBar.secondaryTextColor)), - maximumNumberOfLines: 2 - )), - environment: {}, - containerSize: CGSize(width: width - 4.0 * 2.0, height: 100.0) - ) - - let contentSize: CGFloat = iconSize.height + spacing + titleSize.height - let size = CGSize(width: width, height: contentSize) - - let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: 0.0), size: iconSize) - let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) - - if let iconView = self.icon.view { - if iconView.superview == nil { - iconView.isUserInteractionEnabled = false - self.containerButton.addSubview(iconView) - } - iconView.frame = iconFrame + if lhs.theme !== rhs.theme { + return false } - - if let titleView = self.title.view { - if titleView.superview == nil { - titleView.isUserInteractionEnabled = false - self.containerButton.addSubview(titleView) - } - titleView.frame = titleFrame + if lhs.strings !== rhs.strings { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false } - - transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) - - self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) - self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) - self.containerNode.frame = CGRect(origin: CGPoint(), size: size) - - return size - } - } - - private final class ScrollView: UIScrollView { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) - } - - override func touchesShouldCancel(in view: UIView) -> Bool { return true } + + final class View: UIView { + private let containerButton: UIView + + private let icon = ComponentView() + private let title = ComponentView() + + private var tapRecognizer: UITapGestureRecognizer? + + private var component: VerticalAllItemComponent? + + override init(frame: CGRect) { + self.containerButton = UIView() + + super.init(frame: frame) + + self.addSubview(self.containerButton) + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func update(component: VerticalAllItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + let topInset: CGFloat = 6.0 + let bottomInset: CGFloat = 8.0 + + let spacing: CGFloat = 1.0 + + let iconSize = self.icon.update( + transition: .immediate, + component: AnyComponent(BundleIconComponent( + name: "Chat List/Tabs/IconChats", + tintColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + //TODO:localize + let titleText: String = "All" + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.regular(10.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 4.0 * 2.0, height: 100.0) + ) + + let contentSize: CGFloat = topInset + bottomInset + iconSize.height + spacing + titleSize.height + let size = CGSize(width: availableSize.width, height: contentSize) + + let iconFrame = CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) * 0.5), y: topInset), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) + + if let iconView = self.icon.view { + if iconView.superview == nil { + iconView.isUserInteractionEnabled = false + self.containerButton.addSubview(iconView) + } + iconView.frame = iconFrame + } + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } } - private enum ScrollId: Equatable { + private final class HorizontalAllItemComponent: Component, AllItemComponent { + let isSelected: Bool + let theme: PresentationTheme + let strings: PresentationStrings + let action: (() -> Void)? + + init(isSelected: Bool, theme: PresentationTheme, strings: PresentationStrings, action: (() -> Void)?) { + self.isSelected = isSelected + self.theme = theme + self.strings = strings + self.action = action + } + + static func ==(lhs: HorizontalAllItemComponent, rhs: HorizontalAllItemComponent) -> Bool { + if lhs === rhs { + return true + } + if lhs.isSelected != rhs.isSelected { + return false + } + if lhs.theme !== rhs.theme { + return false + } + if lhs.strings !== rhs.strings { + return false + } + if (lhs.action == nil) != (rhs.action == nil) { + return false + } + return true + } + + final class View: UIView { + private let containerButton: UIView + + private let title = ComponentView() + + private var tapRecognizer: UITapGestureRecognizer? + + private var component: HorizontalAllItemComponent? + + override init(frame: CGRect) { + self.containerButton = UIView() + + super.init(frame: frame) + + self.addSubview(self.containerButton) + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.containerButton.addGestureRecognizer(tapRecognizer) + tapRecognizer.isEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.component?.action?() + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + var mappedPoint = point + if self.bounds.insetBy(dx: -8.0, dy: -4.0).contains(point) { + mappedPoint = self.bounds.center + } + return super.hitTest(mappedPoint, with: event) + } + + func update(component: HorizontalAllItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.component = component + + self.tapRecognizer?.isEnabled = component.action != nil + + let leftInset: CGFloat = 6.0 + let rightInset: CGFloat = 12.0 + + //TODO:localize + let titleText: String = "All" + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleText, font: Font.medium(14.0), textColor: component.isSelected ? component.theme.rootController.navigationBar.accentTextColor : component.theme.rootController.navigationBar.secondaryTextColor)), + maximumNumberOfLines: 2 + )), + environment: {}, + containerSize: CGSize(width: 200.0, height: 200.0) + ) + + let contentSize: CGFloat = leftInset + rightInset + titleSize.height + let size = CGSize(width: contentSize, height: availableSize.height) + + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - titleSize.height) * 0.5)), size: titleSize) + + if let titleView = self.title.view { + if titleView.superview == nil { + titleView.isUserInteractionEnabled = false + self.containerButton.addSubview(titleView) + } + titleView.frame = titleFrame + } + + transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) + + return size + } + } + + func makeView() -> View { + return View(frame: CGRect()) + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } + } + + private enum ScrollId: Hashable { case all case topic(Int64) } public final class View: UIView { - private let scrollView: ScrollView + private let list = ComponentView() + private let listState = AsyncListComponent.ExternalState() private let scrollContainerView: UIView private let scrollViewMask: UIImageView - private let background = ComponentView() - private let separatorLayer: SimpleLayer - private let selectedLineView: UIImageView + private var background: ComponentView? + private var separatorLayer: SimpleLayer? + + private let selectedLineContainer: AsyncListComponent.OverlayContainerView + private let selectedLineView: UIImageView + private let pinnedBackgroundContainer: AsyncListComponent.OverlayContainerView + private let pinnedBackgroundView: UIImageView + private let pinnedIconView: UIImageView - private var items: [Item] = [] - private var itemViews: [Item.Id: ItemView] = [:] - private var allItemView: AllItemView? private var tabItemView: TabItemView? + private var rawItems: [Item] = [] + private var reorderingItems: [Item]? + private var resetReorderingOnNextUpdate: Bool = false + private var itemsContentVersion: Int = 0 + + private var isTogglingPinnedItem: Bool = false + private weak var dismissContextControllerOnNextUpdate: ContextController? + private var component: ChatSideTopicsPanel? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private var appliedScrollToId: ScrollId? + private var isReordering: Bool = false private var itemsDisposable: Disposable? override public init(frame: CGRect) { self.selectedLineView = UIImageView() - self.scrollView = ScrollView(frame: CGRect()) + self.selectedLineView.isHidden = true + self.selectedLineContainer = AsyncListComponent.OverlayContainerView() + self.selectedLineContainer.addSubview(self.selectedLineView) + + self.pinnedIconView = UIImageView() + self.pinnedBackgroundView = UIImageView() + self.pinnedBackgroundContainer = AsyncListComponent.OverlayContainerView() + self.pinnedBackgroundContainer.addSubview(self.pinnedIconView) + self.pinnedBackgroundContainer.addSubview(self.pinnedBackgroundView) + self.pinnedBackgroundContainer.isHidden = true self.scrollContainerView = UIView() - self.scrollViewMask = UIImageView(image: generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ - UIColor(white: 1.0, alpha: 0.0), - UIColor(white: 1.0, alpha: 1.0) - ], locations: [0.0, 1.0], direction: .vertical)?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 8)) + self.scrollViewMask = UIImageView() self.scrollContainerView.mask = self.scrollViewMask - self.separatorLayer = SimpleLayer() - super.init(frame: frame) - self.scrollView.delaysContentTouches = false - self.scrollView.canCancelContentTouches = true - self.scrollView.clipsToBounds = true - self.scrollView.contentInsetAdjustmentBehavior = .never - if #available(iOS 13.0, *) { - self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - } - self.scrollView.showsVerticalScrollIndicator = false - self.scrollView.showsHorizontalScrollIndicator = false - self.scrollView.alwaysBounceHorizontal = false - self.scrollView.alwaysBounceVertical = false - self.scrollView.scrollsToTop = false - self.addSubview(self.scrollContainerView) - self.scrollContainerView.addSubview(self.scrollView) - self.scrollView.addSubview(self.selectedLineView) + self.scrollContainerView.addSubview(self.pinnedBackgroundContainer) + self.scrollContainerView.addSubview(self.selectedLineContainer) } required public init?(coder: NSCoder) { @@ -652,14 +1460,22 @@ public final class ChatSideTopicsPanel: Component { } public func updateGlobalOffset(globalOffset: CGFloat, transition: ComponentTransition) { + guard let component = self.component else { + return + } if let tabItemView = self.tabItemView { - transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(-globalOffset, 0.0, 0.0)) + switch component.location { + case .side: + transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(-globalOffset, 0.0, 0.0)) + case .top: + transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) + } } } public func topicIndex(threadId: Int64?) -> Int? { if let threadId { - if let value = self.items.firstIndex(where: { item in + if let value = self.rawItems.firstIndex(where: { item in if item.id == .chatList(PeerId(threadId)) { return true } else if item.id == .forum(threadId) { @@ -677,6 +1493,139 @@ public final class ChatSideTopicsPanel: Component { } } + private func updateListOverlays(visibleItems: AsyncListComponent.VisibleItems, transition: ComponentTransition) { + guard let component = self.component, let listView = self.list.view else { + return + } + + var selectedItemFrame: CGRect? + var beforePinnedItemsPosition: CGFloat? + var afterPinnedItemsPosition: CGFloat? + var seenPinnedItems = false + for item in visibleItems { + if let _ = item.item.component.wrapped as? AllItemComponent { + if component.topicId == nil { + switch component.location { + case .side: + selectedItemFrame = item.frame + case .top: + selectedItemFrame = CGRect(origin: CGPoint(x: item.frame.minX + 5.0, y: item.frame.minY), size: CGSize(width: item.frame.width - 4.0 - 11.0, height: item.frame.height)) + } + } + if !seenPinnedItems { + switch component.location { + case .side: + beforePinnedItemsPosition = item.frame.maxY + case .top: + beforePinnedItemsPosition = item.frame.maxX + } + } + } else if let itemComponent = item.item.component.wrapped as? ItemComponent { + let topicId: Int64 + switch itemComponent.item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + } + if topicId == component.topicId { + selectedItemFrame = item.frame + } + + var isPinned = false + if case let .forum(pinnedIndex, _, _, _, _) = itemComponent.item.item.index { + if case .index = pinnedIndex { + isPinned = true + } + } + if isPinned { + seenPinnedItems = true + } else { + if !seenPinnedItems { + switch component.location { + case .side: + beforePinnedItemsPosition = item.frame.maxY + case .top: + beforePinnedItemsPosition = item.frame.maxX + } + } else { + if afterPinnedItemsPosition == nil { + switch component.location { + case .side: + afterPinnedItemsPosition = item.frame.minY + case .top: + afterPinnedItemsPosition = item.frame.minX + } + } + } + } + } + } + + if seenPinnedItems { + if beforePinnedItemsPosition == nil { + beforePinnedItemsPosition = -500.0 + } + if afterPinnedItemsPosition == nil { + switch component.location { + case .side: + afterPinnedItemsPosition = listView.bounds.height + 500.0 + case .top: + afterPinnedItemsPosition = listView.bounds.width + 500.0 + } + } + } + + if let selectedItemFrame { + var lineTransition = transition + if self.selectedLineView.isHidden { + self.selectedLineView.isHidden = false + lineTransition = .immediate + } + let selectedLineFrame: CGRect + switch component.location { + case .side: + selectedLineFrame = CGRect(origin: CGPoint(x: 0.0, y: selectedItemFrame.minY), size: CGSize(width: 4.0, height: selectedItemFrame.height)) + case .top: + selectedLineFrame = CGRect(origin: CGPoint(x: selectedItemFrame.minX, y: listView.frame.maxY - 3.0), size: CGSize(width: selectedItemFrame.width, height: 3.0)) + } + + self.selectedLineContainer.updatePosition(position: selectedLineFrame.origin, transition: lineTransition) + lineTransition.setFrame(view: self.selectedLineView, frame: CGRect(origin: CGPoint(), size: selectedLineFrame.size)) + } else { + self.selectedLineView.isHidden = true + } + + if let beforePinnedItemsPosition, let afterPinnedItemsPosition, afterPinnedItemsPosition > beforePinnedItemsPosition { + var pinnedItemsTransition = transition + if self.pinnedBackgroundContainer.isHidden { + self.pinnedBackgroundContainer.isHidden = false + pinnedItemsTransition = .immediate + } + let pinnedItemsBackgroundFrame: CGRect + switch component.location { + case .side: + pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: 5.0, y: beforePinnedItemsPosition), size: CGSize(width: listView.bounds.width - 5.0 - 4.0, height: afterPinnedItemsPosition - beforePinnedItemsPosition)) + case .top: + pinnedItemsBackgroundFrame = CGRect(origin: CGPoint(x: beforePinnedItemsPosition, y: 4.0), size: CGSize(width: afterPinnedItemsPosition - beforePinnedItemsPosition, height: listView.bounds.height - 5.0 - 4.0)) + } + self.pinnedBackgroundContainer.updatePosition(position: pinnedItemsBackgroundFrame.origin, transition: pinnedItemsTransition) + pinnedItemsTransition.setFrame(view: self.pinnedBackgroundView, frame: CGRect(origin: CGPoint(), size: pinnedItemsBackgroundFrame.size)) + + let pinnedIconFrame = CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: CGSize(width: 12.0, height: 12.0)) + pinnedItemsTransition.setFrame(view: self.pinnedIconView, frame: pinnedIconFrame) + } else { + self.pinnedBackgroundContainer.isHidden = true + } + } + + private func updateIsReordering(isReordering: Bool) { + self.isReordering = isReordering + if !self.isUpdating { + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + func update(component: ChatSideTopicsPanel, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -685,6 +1634,12 @@ public final class ChatSideTopicsPanel: Component { self.state = state + if self.resetReorderingOnNextUpdate { + self.resetReorderingOnNextUpdate = false + self.reorderingItems = nil + self.isReordering = false + } + if self.component == nil { let threadListSignal: Signal = component.context.sharedContext.subscribeChatListData(context: component.context, location: component.isMonoforum ? .savedMessagesChats(peerId: component.peerId) : .forum(peerId: component.peerId)) @@ -693,59 +1648,121 @@ public final class ChatSideTopicsPanel: Component { guard let self else { return } - self.items.removeAll() + let wasEmpty = self.rawItems.isEmpty + + self.rawItems.removeAll() for item in chatList.items.reversed() { - self.items.append(Item(item: item)) + self.rawItems.append(Item(item: item)) + } + + if self.reorderingItems != nil { + self.reorderingItems = self.rawItems } if !self.isUpdating { - self.state?.updated(transition: .immediate) + self.state?.updated(transition: (wasEmpty || self.isTogglingPinnedItem) ? .immediate : .spring(duration: 0.4)) } }) + + switch component.location { + case .side: + self.scrollViewMask.image = generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ + UIColor(white: 1.0, alpha: 0.0), + UIColor(white: 1.0, alpha: 1.0) + ], locations: [0.0, 1.0], direction: .vertical)?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 8) + case .top: + self.scrollViewMask.image = generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ + UIColor(white: 1.0, alpha: 0.0), + UIColor(white: 1.0, alpha: 1.0) + ], locations: [0.0, 1.0], direction: .horizontal)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0) + } } let themeUpdated = self.component?.theme !== component.theme self.component = component - let _ = self.background.update( - transition: transition, - component: AnyComponent(BlurredBackgroundComponent( - color: component.theme.rootController.navigationBar.blurredBackgroundColor - )), - environment: {}, - containerSize: availableSize - ) - self.separatorLayer.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor - - if let backgroundView = self.background.view { - if backgroundView.superview == nil { - self.insertSubview(backgroundView, at: 0) + if case .side = component.location { + let background: ComponentView + if let current = self.background { + background = current + } else { + background = ComponentView() + self.background = background } - transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: availableSize)) + let _ = background.update( + transition: transition, + component: AnyComponent(BlurredBackgroundComponent( + color: component.theme.rootController.navigationBar.blurredBackgroundColor + )), + environment: {}, + containerSize: availableSize + ) + + if let backgroundView = background.view { + if backgroundView.superview == nil { + self.insertSubview(backgroundView, at: 0) + } + transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: availableSize)) + } + + let separatorLayer: SimpleLayer + if let current = self.separatorLayer { + separatorLayer = current + } else { + separatorLayer = SimpleLayer() + self.separatorLayer = separatorLayer + self.layer.addSublayer(separatorLayer) + } + if themeUpdated { + separatorLayer.backgroundColor = component.theme.rootController.navigationBar.separatorColor.cgColor + } + + transition.setFrame(layer: separatorLayer, frame: CGRect(origin: CGPoint(x: availableSize.width, y: 0.0), size: CGSize(width: UIScreenPixel, height: availableSize.height))) } - if self.separatorLayer.superlayer == nil { - self.layer.addSublayer(self.separatorLayer) - } - transition.setFrame(layer: self.separatorLayer, frame: CGRect(origin: CGPoint(x: availableSize.width, y: 0.0), size: CGSize(width: UIScreenPixel, height: availableSize.height))) if themeUpdated { - self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 7.0), rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) - })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 4) + switch component.location { + case .side: + self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 7.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height))) + })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 4) + case .top: + self.selectedLineView.image = generateImage(CGSize(width: 4.0, height: 3.0), rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(component.theme.rootController.navigationBar.accentTextColor.cgColor) + context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.height * 2.0)), cornerRadius: 2.0).cgPath) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: 2, topCapHeight: 1) + } + + if self.pinnedIconView.image == nil { + self.pinnedIconView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/Pinned"), color: .white)?.withRenderingMode(.alwaysTemplate) + } + self.pinnedIconView.tintColor = component.theme.chatList.unreadBadgeInactiveBackgroundColor + + if self.pinnedBackgroundView.image == nil { + self.pinnedBackgroundView.image = generateStretchableFilledCircleImage(diameter: 10.0, color: .white)?.withRenderingMode(.alwaysTemplate) + } + var pinnedBackgroundColor = component.theme.rootController.navigationSearchBar.inputFillColor + if pinnedBackgroundColor.distance(to: component.theme.list.blocksBackgroundColor) < 100 { + pinnedBackgroundColor = pinnedBackgroundColor.withMultipliedBrightnessBy(0.8) + } + self.pinnedBackgroundView.tintColor = pinnedBackgroundColor } - let hadItemViews = !self.itemViews.isEmpty - let environment = environment[EnvironmentType.self].value let containerInsets = environment.insets - let panelWidth: CGFloat = availableSize.width - containerInsets.left - let itemSpacing: CGFloat = 24.0 - - var topContainerInset: CGFloat = containerInsets.top + var directionContainerInset: CGFloat + switch component.location { + case .side: + directionContainerInset = containerInsets.top + case .top: + directionContainerInset = containerInsets.left + } do { var itemTransition = transition @@ -760,69 +1777,51 @@ public final class ChatSideTopicsPanel: Component { guard let self, let component = self.component else { return } - component.togglePanel() + if self.isReordering { + if let reorderingItems = self.reorderingItems { + var threadIds: [Int64] = [] + for item in reorderingItems { + if case let .forum(pinnedIndex, _, threadId, _, _) = item.item.index, case .index = pinnedIndex { + threadIds.append(threadId) + } + } + + var currentThreadIds: [Int64] = [] + for item in self.rawItems { + if case let .forum(pinnedIndex, _, threadId, _, _) = item.item.index, case .index = pinnedIndex { + currentThreadIds.append(threadId) + } + } + + if threadIds != currentThreadIds { + let _ = component.context.engine.peers.setForumChannelPinnedTopics(id: component.peerId, threadIds: threadIds).startStandalone() + self.resetReorderingOnNextUpdate = true + } else { + self.reorderingItems = nil + self.isReordering = false + self.state?.updated(transition: .spring(duration: 0.4)) + } + } else { + self.isReordering = false + self.state?.updated(transition: .spring(duration: 0.4)) + } + } else { + component.togglePanel() + } }) self.tabItemView = itemView self.addSubview(itemView) } - - let itemSize = itemView.update(context: component.context, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: itemSize) - itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) - itemTransition.setBounds(layer: itemView.layer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - - if animateIn && !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) - transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) - } - - topContainerInset += itemSize.height - topContainerInset -= 24.0 - } - - var contentSize = CGSize(width: panelWidth, height: 0.0) - contentSize.height += 36.0 - - var validIds: [Item.Id] = [] - var isFirst = true - var selectedItemFrame: CGRect? - - do { - if isFirst { - isFirst = false - } else { - contentSize.height += itemSpacing - } - - var itemTransition = transition - var animateIn = false - let itemView: AllItemView - if let current = self.allItemView { - itemView = current - } else { - itemTransition = .immediate - animateIn = true - itemView = AllItemView(context: component.context, action: { [weak self] in - guard let self, let component = self.component else { - return - } - - component.updateTopicId(nil, false) - }) - self.allItemView = itemView - self.scrollView.addSubview(itemView) - } - - var isSelected = false - if component.topicId == nil { - isSelected = true - } - let itemSize = itemView.update(context: component.context, isSelected: isSelected, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: contentSize.height), size: itemSize) - - if isSelected { - selectedItemFrame = itemFrame + let itemSize = itemView.update(context: component.context, theme: component.theme, width: 72.0, location: component.location, isReordering: self.isReordering, transition: itemTransition) + let itemFrame: CGRect + switch component.location { + case .side: + itemFrame = CGRect(origin: CGPoint(x: 0.0, y: directionContainerInset), size: itemSize) + directionContainerInset += itemSize.height + case .top: + itemFrame = CGRect(origin: CGPoint(x: directionContainerInset, y: 0.0), size: itemSize) + directionContainerInset += itemSize.width - 14.0 } itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) @@ -832,122 +1831,24 @@ public final class ChatSideTopicsPanel: Component { itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) } - - contentSize.height += itemSize.height } - for item in self.items { - if isFirst { - isFirst = false - } else { - contentSize.height += itemSpacing - } - let itemId = item.id - validIds.append(itemId) - - var itemTransition = transition - var animateIn = false - let itemView: ItemView - if let current = self.itemViews[itemId] { - itemView = current - } else { - itemTransition = .immediate - animateIn = true - let chatListItem = item.item - itemView = ItemView(context: component.context, action: { [weak self] in - guard let self, let component = self.component else { - return - } - - let topicId: Int64 - if case let .forum(topicIdValue) = chatListItem.id { - topicId = topicIdValue - } else { - topicId = chatListItem.renderedPeer.peerId.toInt64() - } - - var direction = true - if let lhsIndex = self.topicIndex(threadId: component.topicId), let rhsIndex = self.topicIndex(threadId: topicId) { - direction = lhsIndex < rhsIndex - } - - component.updateTopicId(topicId, direction) - }, contextGesture: { gesture, sourceNode in - }) - self.itemViews[itemId] = itemView - self.scrollView.addSubview(itemView) - } - - var isSelected = false - if case let .forum(topicId) = item.item.id { - isSelected = component.topicId == topicId - } else { - isSelected = component.topicId == item.item.renderedPeer.peerId.toInt64() - } - let itemSize = itemView.update(context: component.context, item: item, isSelected: isSelected, theme: component.theme, width: panelWidth, transition: .immediate) - let itemFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: contentSize.height), size: itemSize) - - if isSelected { - selectedItemFrame = itemFrame - } - - itemTransition.setPosition(layer: itemView.layer, position: itemFrame.center) - itemTransition.setBounds(layer: itemView.layer, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) - - if animateIn && !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 0.0, to: itemView.alpha, duration: 0.15) - transition.containedViewLayoutTransition.animateTransformScale(view: itemView, from: 0.001) - } - - contentSize.height += itemSize.height + let scrollSize: CGSize + let scrollFrame: CGRect + let listContentInsets: UIEdgeInsets + switch component.location { + case .side: + scrollSize = CGSize(width: availableSize.width, height: availableSize.height - directionContainerInset) + scrollFrame = CGRect(origin: CGPoint(x: 0.0, y: directionContainerInset), size: scrollSize) + listContentInsets = UIEdgeInsets(top: 8.0, left: 0.0, bottom: 8.0, right: 0.0) + case .top: + scrollSize = CGSize(width: availableSize.width - directionContainerInset, height: availableSize.height) + scrollFrame = CGRect(origin: CGPoint(x: directionContainerInset, y: 0.0), size: scrollSize) + listContentInsets = UIEdgeInsets(top: 0.0, left: 8.0, bottom: 0.0, right: 8.0) } - contentSize.height += 12.0 - - var removedIds: [Item.Id] = [] - for (id, itemView) in self.itemViews { - if !validIds.contains(id) { - removedIds.append(id) - - if !transition.animation.isImmediate { - itemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak itemView] _ in - itemView?.removeFromSuperview() - }) - transition.setScale(layer: itemView.layer, scale: 0.001) - } else { - itemView.removeFromSuperview() - } - } - } - for id in removedIds { - self.itemViews.removeValue(forKey: id) - } - - if let selectedItemFrame { - let lineFrame = CGRect(origin: CGPoint(x: containerInsets.left, y: selectedItemFrame.minY), size: CGSize(width: 4.0, height: selectedItemFrame.height + 4.0)) - if self.selectedLineView.isHidden { - self.selectedLineView.isHidden = false - self.selectedLineView.frame = lineFrame - } else { - transition.setFrame(view: self.selectedLineView, frame: lineFrame) - } - } else { - self.selectedLineView.isHidden = true - } - - contentSize.height += containerInsets.bottom - - let scrollSize = CGSize(width: availableSize.width, height: availableSize.height - topContainerInset) - - self.scrollContainerView.frame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: scrollSize) - self.scrollViewMask.frame = CGRect(origin: CGPoint(x: 0.0, y: topContainerInset), size: scrollSize) - - if self.scrollView.bounds.size != scrollSize { - self.scrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) - } - if self.scrollView.contentSize != contentSize { - self.scrollView.contentSize = contentSize - } + self.scrollContainerView.frame = scrollFrame + self.scrollViewMask.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) let scrollToId: ScrollId if let threadId = component.topicId { @@ -956,17 +1857,378 @@ public final class ChatSideTopicsPanel: Component { scrollToId = .all } if self.appliedScrollToId != scrollToId { - if case let .topic(threadId) = scrollToId { - if let itemView = self.itemViews[.forum(threadId)] { - self.appliedScrollToId = scrollToId - self.scrollView.scrollRectToVisible(itemView.frame.insetBy(dx: -46.0, dy: 0.0), animated: hadItemViews) + self.appliedScrollToId = scrollToId + self.listState.resetScrolling(id: AnyHashable(scrollToId)) + } + + var listItems: [AnyComponentWithIdentity] = [] + switch component.location { + case .side: + listItems.append(AnyComponentWithIdentity( + id: ScrollId.all, + component: AnyComponent(VerticalAllItemComponent( + isSelected: component.topicId == nil, + theme: component.theme, + strings: component.strings, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateTopicId(nil, false) + } + ))) + ) + case .top: + listItems.append(AnyComponentWithIdentity( + id: ScrollId.all, + component: AnyComponent(HorizontalAllItemComponent( + isSelected: component.topicId == nil, + theme: component.theme, + strings: component.strings, + action: { [weak self] in + guard let self, let component = self.component else { + return + } + component.updateTopicId(nil, false) + } + ))) + ) + } + for item in self.reorderingItems ?? self.rawItems { + let scrollId: ScrollId + let topicId: Int64 + var isItemReordering = false + switch item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + if self.isReordering { + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index, case .index = pinnedIndex { + isItemReordering = true + } } - } else if case .all = scrollToId { - self.appliedScrollToId = scrollToId - self.scrollView.scrollRectToVisible(CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)), animated: hadItemViews) - } else { - self.appliedScrollToId = scrollToId } + scrollId = .topic(topicId) + + let itemAction: (() -> Void)? = self.isReordering ? nil : { [weak self] in + guard let self, let component = self.component else { + return + } + + let direction: Bool + if let lhsIndex = self.topicIndex(threadId: component.topicId), let rhsIndex = self.topicIndex(threadId: topicId) { + direction = lhsIndex < rhsIndex + } else { + direction = false + } + component.updateTopicId(topicId, direction) + } + var itemContextGesture: ((ContextGesture, ContextExtractedContentContainingNode) -> Void)? + if !self.isReordering && component.isMonoforum { + itemContextGesture = { [weak self] gesture, sourceNode in + guard let self, let component = self.component else { + return + } + guard let controller = component.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + if let listView = self.list.view as? AsyncListComponent.View { + listView.stopScrolling() + } + + let topicId: Int64 + switch item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: presentationData.strings.ChatList_Context_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] c, _ in + guard let self else { + return + } + + c?.dismiss(completion: { [weak self] in + guard let self, let component = self.component, let controller = component.controller() else { + return + } + + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + items.append(ActionSheetTextItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationText, parseMarkdown: true)) + items.append(ActionSheetButtonItem(title: presentationData.strings.ChatList_DeleteTopicConfirmationAction, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + guard let self, let component = self.component else { + return + } + + if component.topicId == topicId { + component.updateTopicId(nil, false) + } + + let _ = component.context.engine.peers.removeForumChannelThread(id: component.peerId, threadId: topicId).startStandalone(completed: { + }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + controller.present(actionSheet, in: .window(.root)) + }) + }))) + + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ItemExtractedContentSource( + sourceNode: sourceNode, + containerView: self, + keepInPlace: false + )), + items: .single(ContextController.Items(content: .list(items))), + recognizer: nil, + gesture: gesture + ) + controller.presentInGlobalOverlay(contextController) + } + } else if !self.isReordering { + itemContextGesture = { [weak self] gesture, sourceNode in + guard let self, let component = self.component else { + return + } + guard let controller = component.controller() else { + return + } + + let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 }) + + if let listView = self.list.view as? AsyncListComponent.View { + listView.stopScrolling() + } + + let topicId: Int64 + switch item.item.id { + case let .chatList(peerId): + topicId = peerId.toInt64() + case let .forum(topicIdValue): + topicId = topicIdValue + } + + var isPinned = false + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index { + if case .index = pinnedIndex { + isPinned = true + } + } + let isClosed = item.item.threadData?.isClosed + let threadData = item.item.threadData + + let _ = (chatForumTopicMenuItems( + context: component.context, + peerId: component.peerId, + threadId: topicId, + isPinned: isPinned, + isClosed: isClosed, + chatListController: controller, + joined: true, + canSelect: false, + customEdit: { [weak self] contextController in + contextController.dismiss(completion: { + guard let self, let component = self.component, let threadData else { + return + } + let editController = component.context.sharedContext.makeEditForumTopicScreen( + context: component.context, + peerId: component.peerId, + threadId: topicId, + threadInfo: threadData.info, + isHidden: threadData.isHidden + ) + component.controller()?.push(editController) + }) + }, + customPinUnpin: { [weak self] contextController in + guard let self, let component = self.component else { + contextController.dismiss(completion: {}) + return + } + + self.isTogglingPinnedItem = true + self.dismissContextControllerOnNextUpdate = contextController + + let _ = (component.context.engine.peers.toggleForumChannelTopicPinned(id: component.peerId, threadId: topicId) + |> deliverOnMainQueue).startStandalone(error: { [weak self, weak contextController] error in + guard let self, let component = self.component else { + contextController?.dismiss(completion: {}) + return + } + + switch error { + case let .limitReached(count): + contextController?.dismiss(completion: {}) + if let controller = component.controller() { + let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } + let text = presentationData.strings.ChatList_MaxThreadPinsFinalText(Int32(count)) + controller.present(textAlertController(context: component.context, title: presentationData.strings.Premium_LimitReached, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})], parseMarkdown: true), in: .window(.root)) + } + default: + break + } + }) + }, + reorder: { [weak self] in + guard let self else { + return + } + self.updateIsReordering(isReordering: true) + } + ) + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self, weak sourceNode, weak gesture] items in + guard let self, let component = self.component else { + return + } + guard let controller = component.controller() else { + return + } + guard let sourceNode else { + return + } + + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ItemExtractedContentSource( + sourceNode: sourceNode, + containerView: self, + keepInPlace: false + )), + items: .single(ContextController.Items(content: .list(items))), + recognizer: nil, + gesture: gesture + ) + controller.presentInGlobalOverlay(contextController) + }) + } + } + + switch component.location { + case .side: + listItems.append(AnyComponentWithIdentity( + id: scrollId, + component: AnyComponent(VerticalItemComponent( + context: component.context, + item: item, + isSelected: component.topicId == topicId, + isReordering: isItemReordering, + theme: component.theme, + strings: component.strings, + action: itemAction, + contextGesture: itemContextGesture + ))) + ) + case .top: + listItems.append(AnyComponentWithIdentity( + id: scrollId, + component: AnyComponent(HorizontalItemComponent( + context: component.context, + item: item, + isSelected: component.topicId == topicId, + isReordering: isItemReordering, + theme: component.theme, + strings: component.strings, + action: itemAction, + contextGesture: itemContextGesture + ))) + ) + } + } + + let _ = self.list.update( + transition: transition, + component: AnyComponent(AsyncListComponent( + externalState: self.listState, + items: listItems, + itemSetId: AnyHashable(self.itemsContentVersion), + direction: component.location == .side ? .vertical : .horizontal, + insets: listContentInsets, + reorderItems: { [weak self] fromIndex, toIndex in + guard let self else { + return false + } + if !self.isReordering { + return false + } + + if self.reorderingItems == nil { + self.reorderingItems = self.rawItems + } + if var reorderingItems = self.reorderingItems { + var maxToIndex = -1 + for item in reorderingItems { + if case let .forum(pinnedIndex, _, _, _, _) = item.item.index, case .index = pinnedIndex { + maxToIndex += 1 + } else { + break + } + } + + let fromItemIndex = fromIndex - 1 + // Account for synthesized "all" item: [all, item_0, item_1, ...] + let toItemIndex = max(0, min(maxToIndex, toIndex - 1)) + if fromItemIndex == toItemIndex { + return false + } + + let reorderingItem = reorderingItems[fromItemIndex] + if toItemIndex < fromItemIndex { + reorderingItems.remove(at: fromItemIndex) + reorderingItems.insert(reorderingItem, at: toItemIndex) + } else { + reorderingItems.insert(reorderingItem, at: toItemIndex + 1) + reorderingItems.remove(at: fromItemIndex) + } + + self.reorderingItems = reorderingItems + self.state?.updated(transition: .spring(duration: 0.4)) + } + + return true + }, + onVisibleItemsUpdated: { [weak self] visibleItems, transition in + guard let self else { + return + } + self.updateListOverlays(visibleItems: visibleItems, transition: transition) + } + )), + environment: {}, + containerSize: scrollSize + ) + if let listView = self.list.view { + if listView.superview == nil { + self.scrollContainerView.addSubview(listView) + } + transition.setFrame(view: listView, frame: CGRect(origin: CGPoint(), size: scrollSize)) + } + + if self.isTogglingPinnedItem { + self.isTogglingPinnedItem = false + } + if let dismissContextControllerOnNextUpdate = self.dismissContextControllerOnNextUpdate { + self.dismissContextControllerOnNextUpdate = nil + dismissContextControllerOnNextUpdate.dismiss(completion: {}) } return availableSize @@ -981,3 +2243,35 @@ public final class ChatSideTopicsPanel: Component { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } + +private final class ItemExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool + let ignoreContentTouches: Bool = true + let blurBackground: Bool = true + let adjustContentForSideInset: Bool = true + + private let sourceNode: ContextExtractedContentContainingNode + private weak var containerView: UIView? + + init(sourceNode: ContextExtractedContentContainingNode, containerView: UIView, keepInPlace: Bool) { + self.sourceNode = sourceNode + self.containerView = containerView + self.keepInPlace = keepInPlace + } + + func takeView() -> ContextControllerTakeViewInfo? { + var contentArea: CGRect? + if let containerView = self.containerView { + contentArea = containerView.convert(containerView.bounds, to: nil) + } + + return ContextControllerTakeViewInfo( + containingItem: .node(self.sourceNode), + contentAreaInScreenSpace: contentArea ?? UIScreen.main.bounds + ) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD b/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD new file mode 100644 index 0000000000..4f2e41640c --- /dev/null +++ b/submodules/TelegramUI/Components/MaskedContainerComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "MaskedContainerComponent", + module_name = "MaskedContainerComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift b/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift new file mode 100644 index 0000000000..c83fff8f83 --- /dev/null +++ b/submodules/TelegramUI/Components/MaskedContainerComponent/Sources/MaskedContainerComponent.swift @@ -0,0 +1,94 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class MaskedContainerView: UIView { + public struct Item: Equatable { + public enum Shape: Equatable { + case ellipse + case roundedRect(cornerRadius: CGFloat) + } + + public var frame: CGRect + public var shape: Shape + + public init(frame: CGRect, shape: Shape) { + self.frame = frame + self.shape = shape + } + } + + private struct Params: Equatable { + let size: CGSize + let items: [Item] + let isInverted: Bool + + init(size: CGSize, items: [Item], isInverted: Bool) { + self.size = size + self.items = items + self.isInverted = isInverted + } + } + + public let contentView: UIView + public let contentMaskView: UIImageView + + private var params: Params? + + override public init(frame: CGRect) { + self.contentView = UIView() + self.contentMaskView = UIImageView() + + super.init(frame: frame) + + self.addSubview(self.contentView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func update(size: CGSize, items: [Item], isInverted: Bool) { + let params = Params(size: size, items: items, isInverted: isInverted) + if self.params == params { + return + } + self.params = params + self.contentView.frame = CGRect(origin: CGPoint(), size: size) + self.contentMaskView.frame = CGRect(origin: CGPoint(), size: size) + + if items.isEmpty { + self.contentMaskView.image = nil + self.contentView.mask = nil + } else { + let renderer = UIGraphicsImageRenderer(bounds: CGRect(origin: CGPoint(), size: size)) + let image = renderer.image { context in + UIGraphicsPushContext(context.cgContext) + + if isInverted { + context.cgContext.setFillColor(UIColor.black.cgColor) + context.cgContext.fill(CGRect(origin: CGPoint(), size: size)) + + context.cgContext.setFillColor(UIColor.clear.cgColor) + context.cgContext.setBlendMode(.copy) + } + + for item in items { + switch item.shape { + case .ellipse: + context.cgContext.fillEllipse(in: item.frame) + case let .roundedRect(cornerRadius): + context.cgContext.addPath(UIBezierPath(roundedRect: item.frame, cornerRadius: cornerRadius).cgPath) + context.cgContext.fillPath() + } + } + + UIGraphicsPopContext() + } + self.contentMaskView.image = image + + self.contentView.mask = self.contentMaskView + } + } +} diff --git a/submodules/TelegramUI/Components/NotificationExceptionsScreen/Sources/NotificationExceptionsScreen.swift b/submodules/TelegramUI/Components/NotificationExceptionsScreen/Sources/NotificationExceptionsScreen.swift index a75a51de5f..8b20e10e96 100644 --- a/submodules/TelegramUI/Components/NotificationExceptionsScreen/Sources/NotificationExceptionsScreen.swift +++ b/submodules/TelegramUI/Components/NotificationExceptionsScreen/Sources/NotificationExceptionsScreen.swift @@ -526,7 +526,7 @@ public func threadNotificationExceptionsScreen(context: AccountContext, peerId: }, addException: { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let filter: ChatListNodePeersFilter = [.excludeRecent, .doNotSearchMessages, .removeSearchHeader] - let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: filter, forumPeerId: peerId, hasContactSelector: false, title: presentationData.strings.Notifications_AddExceptionTitle)) + let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: filter, forumPeerId: (peerId, false), hasContactSelector: false, title: presentationData.strings.Notifications_AddExceptionTitle)) controller.peerSelected = { [weak controller] _, threadId in guard let threadId = threadId else { return diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 4ea27126a0..cdb138e96b 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -2220,7 +2220,7 @@ private func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostSt })) //TODO:localize - items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPostSuggestionsSettings, label: .text(channel.linkedMonoforumId == nil ? "Off" : "On"), additionalBadgeLabel: presentationData.strings.Settings_New, text: "Message Channel", icon: UIImage(bundleImageName: "Chat/Info/PostSuggestionsIcon"), action: { + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPostSuggestionsSettings, label: .text(channel.linkedMonoforumId == nil ? "Off" : "On"), additionalBadgeLabel: presentationData.strings.Settings_New, text: "Allow Channel Messages", icon: UIImage(bundleImageName: "Chat/Info/PostSuggestionsIcon"), action: { interaction.editingOpenPostSuggestionsSetup() })) } diff --git a/submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen/Sources/PostSuggestionsSettingsScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen/Sources/PostSuggestionsSettingsScreen.swift index 7e783c1a31..414dfda134 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen/Sources/PostSuggestionsSettingsScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PostSuggestionsSettingsScreen/Sources/PostSuggestionsSettingsScreen.swift @@ -124,7 +124,7 @@ final class PostSuggestionsSettingsScreenComponent: Component { } if component.initialPrice != currentAmount { - let _ = component.context.engine.peers.updateChannelPaidMessagesStars(peerId: peer.id, stars: currentAmount, broadcastMessagesAllowed: true).startStandalone() + let _ = component.context.engine.peers.updateChannelPaidMessagesStars(peerId: peer.id, stars: currentAmount, broadcastMessagesAllowed: currentAmount != nil).startStandalone() } return true @@ -200,11 +200,10 @@ final class PostSuggestionsSettingsScreenComponent: Component { let presentationData = component.context.sharedContext.currentPresentationData.with { $0 } - //TODO:localize let navigationTitleSize = self.navigationTitle.update( transition: transition, component: AnyComponent(MultilineTextComponent( - text: .plain(NSAttributedString(string: "Post Suggestion", font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), + text: .plain(NSAttributedString(string: environment.strings.ChannelMessages_Title, font: Font.semibold(17.0), textColor: environment.theme.rootController.navigationBar.primaryTextColor)), horizontalAlignment: .center )), environment: {}, @@ -249,8 +248,7 @@ final class PostSuggestionsSettingsScreenComponent: Component { contentHeight += 129.0 - //TODO:localize - let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString("Allow users to suggest posts for your channel.", attributes: MarkdownAttributes( + let subtitleString = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(environment.strings.ChannelMessages_Info, attributes: MarkdownAttributes( body: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), link: MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.itemAccentColor), @@ -301,7 +299,7 @@ final class PostSuggestionsSettingsScreenComponent: Component { title: AnyComponent(VStack([ AnyComponentWithIdentity(id: AnyHashable(0), component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Allow Post Suggestions", + string: environment.strings.ChannelMessages_SwitchTitle, font: Font.regular(presentationData.listsFontSize.baseDisplaySize), textColor: environment.theme.list.itemPrimaryTextColor )), @@ -385,26 +383,6 @@ final class PostSuggestionsSettingsScreenComponent: Component { ), params: ListViewItemLayoutParams(width: availableSize.width - sideInset * 2.0, leftInset: 0.0, rightInset: 0.0, availableHeight: 10000.0, isStandalone: true) )))) - /*contentSectionItems.append(AnyComponentWithIdentity(id: 0, component: AnyComponent(ListItemSliderSelectorComponent( - theme: environment.theme, - content: .discrete(ListItemSliderSelectorComponent.Discrete( - values: sliderValueList.map { item in - return item - }, - markPositions: false, - selectedIndex: max(0, min(sliderValueList.count - 1, self.starCount - 1)), - title: sliderTitle, - secondaryTitle: sliderSecondaryTitle, - selectedIndexUpdated: { [weak self] index in - guard let self else { - return - } - let index = max(0, min(sliderValueList.count, index)) - self.starCount = index - self.state?.updated(transition: .immediate) - } - )) - ))))*/ let contentSectionSize = self.contentSection.update( transition: transition, @@ -412,7 +390,7 @@ final class PostSuggestionsSettingsScreenComponent: Component { theme: environment.theme, header: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "PRICE FOR EACH SUGGESTION", + string: environment.strings.ChannelMessages_PriceSectionTitle, font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), @@ -420,7 +398,7 @@ final class PostSuggestionsSettingsScreenComponent: Component { )), footer: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString( - string: "Charge users for the ability to suggest one post for your channel. You're not required to publish any suggestions by charging this. You'll receive 85% of the selected fee for each incoming suggestion.", + string: environment.strings.ChannelMessages_PriceSectionFooter, font: Font.regular(13.0), textColor: environment.theme.list.freeTextColor )), diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift index 7634568075..ba9f768fad 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionController.swift @@ -22,7 +22,7 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon public var peerSelected: ((EnginePeer, Int64?) -> Void)? public var multiplePeersSelected: (([EnginePeer], [EnginePeer.Id: EnginePeer], NSAttributedString, AttachmentTextInputPanelSendMode, ChatInterfaceForwardOptionsState?, ChatSendMessageActionSheetController.SendParameters?) -> Void)? private let filter: ChatListNodePeersFilter - private let forumPeerId: EnginePeer.Id? + private let forumPeerId: (id: EnginePeer.Id, isMonoforum: Bool)? private let selectForumThreads: Bool private let attemptSelection: ((EnginePeer, Int64?, ChatListDisabledPeerReason) -> Void)? @@ -277,28 +277,45 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon self.peerSelectionNode.requestOpenPeer = { [weak self] peer, threadId in if let strongSelf = self, let peerSelected = strongSelf.peerSelected { if case let .channel(peer) = peer, peer.isForumOrMonoForum, threadId == nil, strongSelf.selectForumThreads { - let controller = PeerSelectionControllerImpl( - PeerSelectionControllerParams( - context: strongSelf.context, - updatedPresentationData: nil, - filter: strongSelf.filter, - forumPeerId: peer.id, - hasFilters: false, - hasChatListSelector: false, - hasContactSelector: false, - hasGlobalSearch: false, - title: EnginePeer(peer).compactDisplayTitle, - attemptSelection: strongSelf.attemptSelection, - createNewGroup: nil, - pretendPresentedInModal: false, - multipleSelection: false, - forwardedMessageIds: [], - hasTypeHeaders: false, - selectForumThreads: false + let mainPeer: Signal + if peer.isMonoForum, let linkedMonoforumId = peer.linkedMonoforumId { + mainPeer = strongSelf.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: linkedMonoforumId) ) - ) - controller.peerSelected = strongSelf.peerSelected - strongSelf.push(controller) + } else { + mainPeer = .single(nil) + } + + let _ = (mainPeer |> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] mainPeer in + guard let strongSelf else { + return + } + + let displayPeer = mainPeer ?? EnginePeer(peer) + + let controller = PeerSelectionControllerImpl( + PeerSelectionControllerParams( + context: strongSelf.context, + updatedPresentationData: nil, + filter: strongSelf.filter, + forumPeerId: (peer.id, peer.isMonoForum), + hasFilters: false, + hasChatListSelector: false, + hasContactSelector: false, + hasGlobalSearch: false, + title: displayPeer.compactDisplayTitle, + attemptSelection: strongSelf.attemptSelection, + createNewGroup: nil, + pretendPresentedInModal: false, + multipleSelection: false, + forwardedMessageIds: [], + hasTypeHeaders: false, + selectForumThreads: false + ) + ) + controller.peerSelected = strongSelf.peerSelected + strongSelf.push(controller) + }) } else { peerSelected(peer, threadId) } @@ -322,7 +339,7 @@ public final class PeerSelectionControllerImpl: ViewController, PeerSelectionCon context: strongSelf.context, updatedPresentationData: nil, filter: strongSelf.filter, - forumPeerId: peer.id, + forumPeerId: (peer.id, peer.isMonoForum), hasFilters: false, hasChatListSelector: false, hasContactSelector: false, diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 4c035c1011..5dbaef2fb7 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -33,7 +33,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { private let presentInGlobalOverlay: (ViewController, Any?) -> Void private let dismiss: () -> Void private let filter: ChatListNodePeersFilter - private let forumPeerId: EnginePeer.Id? + private let forumPeerId: (id: EnginePeer.Id, isMonoforum: Bool)? private let hasGlobalSearch: Bool private let forwardedMessageIds: [EngineMessage.Id] private let hasTypeHeaders: Bool @@ -109,7 +109,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { return (self.presentationData, self.presentationDataPromise.get()) } - init(context: AccountContext, controller: PeerSelectionControllerImpl, presentationData: PresentationData, filter: ChatListNodePeersFilter, forumPeerId: EnginePeer.Id?, hasFilters: Bool, hasChatListSelector: Bool, hasContactSelector: Bool, hasGlobalSearch: Bool, forwardedMessageIds: [EngineMessage.Id], hasTypeHeaders: Bool, requestPeerType: [ReplyMarkupButtonRequestPeerType]?, hasCreation: Bool, createNewGroup: (() -> Void)?, present: @escaping (ViewController, Any?) -> Void, presentInGlobalOverlay: @escaping (ViewController, Any?) -> Void, dismiss: @escaping () -> Void) { + init(context: AccountContext, controller: PeerSelectionControllerImpl, presentationData: PresentationData, filter: ChatListNodePeersFilter, forumPeerId: (id: EnginePeer.Id, isMonoforum: Bool)?, hasFilters: Bool, hasChatListSelector: Bool, hasContactSelector: Bool, hasGlobalSearch: Bool, forwardedMessageIds: [EngineMessage.Id], hasTypeHeaders: Bool, requestPeerType: [ReplyMarkupButtonRequestPeerType]?, hasCreation: Bool, createNewGroup: (() -> Void)?, present: @escaping (ViewController, Any?) -> Void, presentInGlobalOverlay: @escaping (ViewController, Any?) -> Void, dismiss: @escaping () -> Void) { self.context = context self.controller = controller self.present = present @@ -193,8 +193,12 @@ final class PeerSelectionControllerNode: ASDisplayNode { } let chatListLocation: ChatListControllerLocation - if let forumPeerId = self.forumPeerId { - chatListLocation = .forum(peerId: forumPeerId) + if let (forumPeerId, isMonoforum) = self.forumPeerId { + if isMonoforum { + chatListLocation = .savedMessagesChats(peerId: forumPeerId) + } else { + chatListLocation = .forum(peerId: forumPeerId) + } } else { chatListLocation = .chatList(groupId: .root) } @@ -248,12 +252,46 @@ final class PeerSelectionControllerNode: ASDisplayNode { } self.chatListNode?.peerSelected = { [weak self] peer, threadId, _, _, _ in - self?.chatListNode?.clearHighlightAnimated(true) - self?.requestOpenPeer?(peer, threadId) + guard let self else { + return + } + + if let (peerId, isMonoforum) = self.forumPeerId, isMonoforum { + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] mainPeer in + guard let self, let mainPeer else { + return + } + self.chatListNode?.clearHighlightAnimated(true) + self.requestOpenPeer?(mainPeer, peer.id.toInt64()) + }) + } else { + self.chatListNode?.clearHighlightAnimated(true) + self.requestOpenPeer?(peer, threadId) + } } self.mainContainerNode?.peerSelected = { [weak self] peer, threadId, _, _, _ in - self?.chatListNode?.clearHighlightAnimated(true) - self?.requestOpenPeer?(peer, threadId) + guard let self else { + return + } + + if let (peerId, isMonoforum) = self.forumPeerId, isMonoforum { + let _ = (self.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId) + ) + |> deliverOnMainQueue).startStandalone(next: { [weak self] mainPeer in + guard let self, let mainPeer else { + return + } + self.chatListNode?.clearHighlightAnimated(true) + self.requestOpenPeer?(mainPeer, peer.id.toInt64()) + }) + } else { + self.chatListNode?.clearHighlightAnimated(true) + self.requestOpenPeer?(peer, threadId) + } } self.chatListNode?.disabledPeerSelected = { [weak self] peer, threadId, reason in @@ -1212,8 +1250,12 @@ final class PeerSelectionControllerNode: ASDisplayNode { self.mainContainerNode?.accessibilityElementsHidden = true let chatListLocation: ChatListControllerLocation - if let forumPeerId = self.forumPeerId { - chatListLocation = .forum(peerId: forumPeerId) + if let (forumPeerId, isMonoforum) = self.forumPeerId { + if isMonoforum { + chatListLocation = .savedMessagesChats(peerId: forumPeerId) + } else { + chatListLocation = .forum(peerId: forumPeerId) + } } else { chatListLocation = .chatList(groupId: EngineChatList.Group(.root)) } diff --git a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift index b5340ad252..a34c56bc77 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeCarouselItem/Sources/ThemeCarouselItem.swift @@ -198,7 +198,7 @@ private func generateBorderImage(theme: PresentationTheme, bordered: Bool, selec } -private final class ThemeCarouselThemeItemIconNode : ListViewItemNode { +private final class ThemeCarouselThemeItemIconNode: ListViewItemNode { private let containerNode: ASDisplayNode private let emojiContainerNode: ASDisplayNode private let imageNode: TransformImageNode diff --git a/submodules/TelegramUI/Components/TextBadgeComponent/BUILD b/submodules/TelegramUI/Components/TextBadgeComponent/BUILD new file mode 100644 index 0000000000..1ddb36aac1 --- /dev/null +++ b/submodules/TelegramUI/Components/TextBadgeComponent/BUILD @@ -0,0 +1,19 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "TextBadgeComponent", + module_name = "TextBadgeComponent", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/Display", + "//submodules/ComponentFlow", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift b/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift new file mode 100644 index 0000000000..03476f4996 --- /dev/null +++ b/submodules/TelegramUI/Components/TextBadgeComponent/Sources/TextBadgeComponent.swift @@ -0,0 +1,171 @@ +import Foundation +import UIKit +import Display +import ComponentFlow + +public final class TextBadgeComponent: Component { + public let text: String + public let font: UIFont + public let background: UIColor + public let foreground: UIColor + public let insets: UIEdgeInsets + + public init( + text: String, + font: UIFont, + background: UIColor, + foreground: UIColor, + insets: UIEdgeInsets + ) { + self.text = text + self.font = font + self.background = background + self.foreground = foreground + self.insets = insets + } + + public static func ==(lhs: TextBadgeComponent, rhs: TextBadgeComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + if lhs.font != rhs.font { + return false + } + if lhs.background != rhs.background { + return false + } + if lhs.foreground != rhs.foreground { + return false + } + if lhs.insets != rhs.insets { + return false + } + return true + } + + private struct TextLayout { + var size: CGSize + var opticalBounds: CGRect + + init(size: CGSize, opticalBounds: CGRect) { + self.size = size + self.opticalBounds = opticalBounds + } + } + + public final class View: UIView { + private let backgroundView: UIImageView + private let textContentsView: UIImageView + + private var textLayout: TextLayout? + + private var component: TextBadgeComponent? + + override public init(frame: CGRect) { + self.backgroundView = UIImageView() + + self.textContentsView = UIImageView() + self.textContentsView.layer.anchorPoint = CGPoint() + + super.init(frame: frame) + + self.addSubview(self.backgroundView) + self.addSubview(self.textContentsView) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(component: TextBadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + let previousComponent = self.component + self.component = component + + if component.text != previousComponent?.text || component.font != previousComponent?.font { + let attributedText = NSAttributedString(string: component.text, attributes: [ + NSAttributedString.Key.font: component.font, + NSAttributedString.Key.foregroundColor: UIColor.white + ]) + + var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil) + boundingRect.size.width = ceil(boundingRect.size.width) + boundingRect.size.height = ceil(boundingRect.size.height) + + if let context = DrawingContext(size: boundingRect.size, scale: 0.0, opaque: false, clear: true) { + context.withContext { c in + UIGraphicsPushContext(c) + defer { + UIGraphicsPopContext() + } + + attributedText.draw(at: CGPoint()) + } + var minFilledLineY = Int(context.scaledSize.height) - 1 + var maxFilledLineY = 0 + var minFilledLineX = Int(context.scaledSize.width) - 1 + var maxFilledLineX = 0 + for y in 0 ..< Int(context.scaledSize.height) { + let linePtr = context.bytes.advanced(by: max(0, y) * context.bytesPerRow).assumingMemoryBound(to: UInt32.self) + + for x in 0 ..< Int(context.scaledSize.width) { + let pixelPtr = linePtr.advanced(by: x) + if pixelPtr.pointee != 0 { + minFilledLineY = min(y, minFilledLineY) + maxFilledLineY = max(y, maxFilledLineY) + minFilledLineX = min(x, minFilledLineX) + maxFilledLineX = max(x, maxFilledLineX) + } + } + } + + var opticalBounds = CGRect() + if minFilledLineX <= maxFilledLineX && minFilledLineY <= maxFilledLineY { + opticalBounds.origin.x = CGFloat(minFilledLineX) / context.scale + opticalBounds.origin.y = CGFloat(minFilledLineY) / context.scale + opticalBounds.size.width = CGFloat(maxFilledLineX - minFilledLineX) / context.scale + opticalBounds.size.height = CGFloat(maxFilledLineY - minFilledLineY) / context.scale + } + + self.textContentsView.image = context.generateImage()?.withRenderingMode(.alwaysTemplate) + self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: opticalBounds) + } else { + self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: CGRect(origin: CGPoint(), size: boundingRect.size)) + } + } + + let textSize = self.textLayout?.size ?? CGSize(width: 1.0, height: 1.0) + + var size = CGSize(width: textSize.width + component.insets.left + component.insets.right, height: textSize.height + component.insets.top + component.insets.bottom) + size.width = max(size.width, size.height) + + let backgroundFrame = CGRect(origin: CGPoint(), size: size) + transition.setFrame(view: self.backgroundView, frame: backgroundFrame) + + let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) * 0.5), y: component.insets.top + UIScreenPixel), size: textSize) + /*if let textLayout = self.textLayout { + textFrame.origin.x = textLayout.opticalBounds.minX + floorToScreenPixels((backgroundFrame.width - textLayout.opticalBounds.width) * 0.5) + textFrame.origin.y = textLayout.opticalBounds.minY + floorToScreenPixels((backgroundFrame.height - textLayout.opticalBounds.height) * 0.5) + }*/ + + transition.setPosition(view: self.textContentsView, position: textFrame.origin) + self.textContentsView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) + + if size.height != self.backgroundView.image?.size.height { + self.backgroundView.image = generateStretchableFilledCircleImage(diameter: size.height, color: .white)?.withRenderingMode(.alwaysTemplate) + } + + self.backgroundView.tintColor = component.background + self.textContentsView.tintColor = component.foreground + + return size + } + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift b/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift index 5ff4f37513..2f9a9a7201 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatMessageActionOptions.swift @@ -430,6 +430,9 @@ private func generateChatReplyOptionItems(selfController: ChatControllerImpl, ch if message.minAutoremoveOrClearTimeout == viewOnceTimeout { canReplyInAnotherChat = false } + if let channel = message.peers[message.id.peerId] as? TelegramChannel, channel.isMonoForum { + canReplyInAnotherChat = false + } } if canReplyInAnotherChat { diff --git a/submodules/TelegramUI/Sources/ChatController.swift b/submodules/TelegramUI/Sources/ChatController.swift index 8d073cbc1a..785a3c978a 100644 --- a/submodules/TelegramUI/Sources/ChatController.swift +++ b/submodules/TelegramUI/Sources/ChatController.swift @@ -7835,8 +7835,12 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G if let channel = self.presentationInterfaceState.renderedPeer?.peer as? TelegramChannel, channel.isMonoForum { attributes.removeAll(where: { $0 is SendAsMessageAttribute }) - if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = self.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.sendSomething), let sendAsPeerId = self.presentationInterfaceState.currentSendAsPeerId { - attributes.append(SendAsMessageAttribute(peerId: sendAsPeerId)) + if let linkedMonoforumId = channel.linkedMonoforumId, let mainChannel = self.presentationInterfaceState.renderedPeer?.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + if let sendAsPeerId = self.presentationInterfaceState.currentSendAsPeerId { + attributes.append(SendAsMessageAttribute(peerId: sendAsPeerId)) + } else { + attributes.append(SendAsMessageAttribute(peerId: linkedMonoforumId)) + } } } if let sendAsPeerId = self.presentationInterfaceState.currentSendAsPeerId { diff --git a/submodules/TelegramUI/Sources/ChatControllerContentData.swift b/submodules/TelegramUI/Sources/ChatControllerContentData.swift index 3614bdb994..0dc9f04990 100644 --- a/submodules/TelegramUI/Sources/ChatControllerContentData.swift +++ b/submodules/TelegramUI/Sources/ChatControllerContentData.swift @@ -548,7 +548,7 @@ extension ChatControllerImpl { } else if let channel = peer as? TelegramChannel, channel.isMonoForum { if let linkedMonoforumId = channel.linkedMonoforumId, let mainPeer = peerView.peers[linkedMonoforumId] { //TODO:localize - strongSelf.state.chatTitleContent = .custom("\(mainPeer.debugDisplayTitle) Messages", nil, false) + strongSelf.state.chatTitleContent = .custom(mainPeer.debugDisplayTitle, nil, false) } else { strongSelf.state.chatTitleContent = .custom(channel.debugDisplayTitle, nil, false) } @@ -1447,11 +1447,25 @@ extension ChatControllerImpl { ) } + var currentSendAsPeerId: PeerId? + if let peer = peerView.peers[peerView.peerId] as? TelegramChannel, let cachedData = peerView.cachedData as? CachedChannelData { + if peer.isMonoForum { + if let linkedMonoforumId = peer.linkedMonoforumId, let mainChannel = peerView.peers[linkedMonoforumId] as? TelegramChannel, mainChannel.hasPermission(.sendSomething) { + currentSendAsPeerId = peer.linkedMonoforumId + } else { + currentSendAsPeerId = nil + } + } else { + currentSendAsPeerId = cachedData.sendAsPeerId + } + } + strongSelf.state.renderedPeer = renderedPeer strongSelf.state.savedMessagesTopicPeer = savedMessagesPeer?.peer strongSelf.state.hasSearchTags = hasSearchTags strongSelf.state.hasSavedChats = hasSavedChats strongSelf.state.hasScheduledMessages = hasScheduledMessages + strongSelf.state.currentSendAsPeerId = currentSendAsPeerId } else { let message = messageAndTopic.message diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift index a7115e2b14..ccd523a091 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenAttachmentMenu.swift @@ -80,7 +80,7 @@ extension ChatControllerImpl { if let value = channel.hasBannedPermission(.banSendText, ignoreDefault: canByPassRestrictions) { banSendText = value } - if channel.hasBannedPermission(.banSendPolls, ignoreDefault: canByPassRestrictions) != nil { + if channel.hasBannedPermission(.banSendPolls, ignoreDefault: canByPassRestrictions) != nil || channel.isMonoForum { canSendPolls = false } } else if let group = peer as? TelegramGroup { @@ -764,7 +764,7 @@ extension ChatControllerImpl { if let value = channel.hasBannedPermission(.banSendMedia) { bannedSendMedia = value } - if channel.hasBannedPermission(.banSendPolls) != nil { + if channel.hasBannedPermission(.banSendPolls) != nil || channel.isMonoForum { canSendPolls = false } } else if let group = peer as? TelegramGroup { diff --git a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift index ad379eadb4..a1eb16fc1c 100644 --- a/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift +++ b/submodules/TelegramUI/Sources/ChatControllerOpenMessageShareMenu.swift @@ -169,7 +169,7 @@ extension ChatControllerImpl { let _ = (self.context.engine.data.get( EngineDataList( - peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + peerIds.map(TelegramEngine.EngineData.Item.Peer.RenderedPeer.init) ) ) |> deliverOnMainQueue).startStandalone(next: { [weak self] peerList in @@ -184,17 +184,17 @@ extension ChatControllerImpl { text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many savedMessages = true } else { - if peers.count == 1, let peer = peers.first { + if peers.count == 1, let peer = peers.first?.chatOrMonoforumMainPeer { var peerName = peer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) peerName = peerName.replacingOccurrences(of: "**", with: "") text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_Chat_One(peerName).string : presentationData.strings.Conversation_ForwardTooltip_Chat_Many(peerName).string - } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + } else if peers.count == 2, let firstPeer = peers.first?.chatOrMonoforumMainPeer, let secondPeer = peers.last?.chatOrMonoforumMainPeer { var firstPeerName = firstPeer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) firstPeerName = firstPeerName.replacingOccurrences(of: "**", with: "") var secondPeerName = secondPeer.id == self.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) secondPeerName = secondPeerName.replacingOccurrences(of: "**", with: "") text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_TwoChats_One(firstPeerName, secondPeerName).string : presentationData.strings.Conversation_ForwardTooltip_TwoChats_Many(firstPeerName, secondPeerName).string - } else if let peer = peers.first { + } else if let peer = peers.first?.chatOrMonoforumMainPeer { var peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) peerName = peerName.replacingOccurrences(of: "**", with: "") text = messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_ManyChats_One(peerName, "\(peers.count - 1)").string : presentationData.strings.Conversation_ForwardTooltip_ManyChats_Many(peerName, "\(peers.count - 1)").string diff --git a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift index 1513f1b4fd..392c7e0d92 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceTitlePanelNodes.swift @@ -271,9 +271,13 @@ func sidePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, + location: .side, peerId: peerId, isMonoforum: true, topicId: chatPresentationInterfaceState.chatLocation.threadId, + controller: { [weak interfaceInteraction] in + return interfaceInteraction?.chatController() + }, togglePanel: { [weak interfaceInteraction] in interfaceInteraction?.toggleChatSidebarMode() }, @@ -292,9 +296,13 @@ func sidePanelForChatPresentationInterfaceState(_ chatPresentationInterfaceState context: context, theme: chatPresentationInterfaceState.theme, strings: chatPresentationInterfaceState.strings, + location: .side, peerId: peerId, isMonoforum: false, topicId: chatPresentationInterfaceState.chatLocation.threadId, + controller: { [weak interfaceInteraction] in + return interfaceInteraction?.chatController() + }, togglePanel: { [weak interfaceInteraction] in interfaceInteraction?.toggleChatSidebarMode() }, diff --git a/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift b/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift index ed821174ce..3ceac34a9f 100644 --- a/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatTopicListTitleAccessoryPanelNode.swift @@ -14,173 +14,9 @@ import EmojiStatusComponent import SwiftSignalKit import BundleIconComponent import AvatarNode - -private final class CustomBadgeComponent: Component { - public let text: String - public let font: UIFont - public let background: UIColor - public let foreground: UIColor - public let insets: UIEdgeInsets - - public init( - text: String, - font: UIFont, - background: UIColor, - foreground: UIColor, - insets: UIEdgeInsets - ) { - self.text = text - self.font = font - self.background = background - self.foreground = foreground - self.insets = insets - } - - public static func ==(lhs: CustomBadgeComponent, rhs: CustomBadgeComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.font != rhs.font { - return false - } - if lhs.background != rhs.background { - return false - } - if lhs.foreground != rhs.foreground { - return false - } - if lhs.insets != rhs.insets { - return false - } - return true - } - - private struct TextLayout { - var size: CGSize - var opticalBounds: CGRect - - init(size: CGSize, opticalBounds: CGRect) { - self.size = size - self.opticalBounds = opticalBounds - } - } - - public final class View: UIView { - private let backgroundView: UIImageView - private let textContentsView: UIImageView - - private var textLayout: TextLayout? - - private var component: CustomBadgeComponent? - - override public init(frame: CGRect) { - self.backgroundView = UIImageView() - - self.textContentsView = UIImageView() - self.textContentsView.layer.anchorPoint = CGPoint() - - super.init(frame: frame) - - self.addSubview(self.backgroundView) - self.addSubview(self.textContentsView) - } - - required public init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: CustomBadgeComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - let previousComponent = self.component - self.component = component - - if component.text != previousComponent?.text || component.font != previousComponent?.font { - let attributedText = NSAttributedString(string: component.text, attributes: [ - NSAttributedString.Key.font: component.font, - NSAttributedString.Key.foregroundColor: UIColor.white - ]) - - var boundingRect = attributedText.boundingRect(with: availableSize, options: .usesLineFragmentOrigin, context: nil) - boundingRect.size.width = ceil(boundingRect.size.width) - boundingRect.size.height = ceil(boundingRect.size.height) - - if let context = DrawingContext(size: boundingRect.size, scale: 0.0, opaque: false, clear: true) { - context.withContext { c in - UIGraphicsPushContext(c) - defer { - UIGraphicsPopContext() - } - - attributedText.draw(at: CGPoint()) - } - var minFilledLineY = Int(context.scaledSize.height) - 1 - var maxFilledLineY = 0 - var minFilledLineX = Int(context.scaledSize.width) - 1 - var maxFilledLineX = 0 - for y in 0 ..< Int(context.scaledSize.height) { - let linePtr = context.bytes.advanced(by: max(0, y) * context.bytesPerRow).assumingMemoryBound(to: UInt32.self) - - for x in 0 ..< Int(context.scaledSize.width) { - let pixelPtr = linePtr.advanced(by: x) - if pixelPtr.pointee != 0 { - minFilledLineY = min(y, minFilledLineY) - maxFilledLineY = max(y, maxFilledLineY) - minFilledLineX = min(x, minFilledLineX) - maxFilledLineX = max(x, maxFilledLineX) - } - } - } - - var opticalBounds = CGRect() - if minFilledLineX <= maxFilledLineX && minFilledLineY <= maxFilledLineY { - opticalBounds.origin.x = CGFloat(minFilledLineX) / context.scale - opticalBounds.origin.y = CGFloat(minFilledLineY) / context.scale - opticalBounds.size.width = CGFloat(maxFilledLineX - minFilledLineX) / context.scale - opticalBounds.size.height = CGFloat(maxFilledLineY - minFilledLineY) / context.scale - } - - self.textContentsView.image = context.generateImage()?.withRenderingMode(.alwaysTemplate) - self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: opticalBounds) - } else { - self.textLayout = TextLayout(size: boundingRect.size, opticalBounds: CGRect(origin: CGPoint(), size: boundingRect.size)) - } - } - - let textSize = self.textLayout?.size ?? CGSize(width: 1.0, height: 1.0) - - var size = CGSize(width: textSize.width + component.insets.left + component.insets.right, height: textSize.height + component.insets.top + component.insets.bottom) - size.width = max(size.width, size.height) - - let backgroundFrame = CGRect(origin: CGPoint(), size: size) - transition.setFrame(view: self.backgroundView, frame: backgroundFrame) - - let textFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - textSize.width) * 0.5), y: component.insets.top + UIScreenPixel), size: textSize) - /*if let textLayout = self.textLayout { - textFrame.origin.x = textLayout.opticalBounds.minX + floorToScreenPixels((backgroundFrame.width - textLayout.opticalBounds.width) * 0.5) - textFrame.origin.y = textLayout.opticalBounds.minY + floorToScreenPixels((backgroundFrame.height - textLayout.opticalBounds.height) * 0.5) - }*/ - - transition.setPosition(view: self.textContentsView, position: textFrame.origin) - self.textContentsView.bounds = CGRect(origin: CGPoint(), size: textFrame.size) - - if size.height != self.backgroundView.image?.size.height { - self.backgroundView.image = generateStretchableFilledCircleImage(diameter: size.height, color: .white)?.withRenderingMode(.alwaysTemplate) - } - - self.backgroundView.tintColor = component.background - self.textContentsView.tintColor = component.foreground - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) - } -} +import TextBadgeComponent +import ChatSideTopicsPanel +import ComponentDisplayAdapters final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, ChatControllerCustomNavigationPanelNode { private struct Params: Equatable { @@ -213,31 +49,7 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } } - private final class Item: Equatable { - typealias Id = EngineChatList.Item.Id - - let item: EngineChatList.Item - - var id: Id { - return self.item.id - } - - init(item: EngineChatList.Item) { - self.item = item - } - - public static func ==(lhs: Item, rhs: Item) -> Bool { - if lhs === rhs { - return true - } - if lhs.item != rhs.item { - return false - } - return true - } - } - - private final class ItemView: UIView { + /*private final class ItemView: UIView { private let context: AccountContext private let action: () -> Void @@ -396,11 +208,11 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C badgeSize = badge.update( transition: badgeTransition, - component: AnyComponent(CustomBadgeComponent( - text: "\(readCounters.count)", + component: AnyComponent(TextBadgeComponent( + text: countString(Int64(readCounters.count)), font: Font.regular(12.0), - background: theme.list.itemCheckColors.fillColor, - foreground: theme.list.itemCheckColors.foregroundColor, + background: item.item.isMuted ? theme.chatList.unreadBadgeInactiveBackgroundColor : theme.chatList.unreadBadgeActiveBackgroundColor, + foreground: item.item.isMuted ? theme.chatList.unreadBadgeInactiveTextColor : theme.chatList.unreadBadgeActiveTextColor, insets: UIEdgeInsets(top: 1.0, left: 5.0, bottom: 2.0, right: 5.0) )), environment: {}, @@ -715,99 +527,26 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C return size } - } - - private final class ScrollView: UIScrollView { - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) - } - - override func touchesShouldCancel(in view: UIView) -> Bool { - return true - } - } - - private enum ScrollId: Equatable { - case all - case topic(Int64) - } - - private let context: AccountContext - private let isMonoforum: Bool - - private let scrollView: ScrollView - private let scrollViewContainer: UIView - private let scrollViewMask: UIImageView + }*/ private var params: Params? - private var items: [Item] = [] - private var itemViews: [Item.Id: ItemView] = [:] - private var allItemView: AllItemView? - private var tabItemView: TabItemView? - private let selectedLineView: UIImageView - - private var itemsDisposable: Disposable? - - private var appliedScrollToId: ScrollId? + private let context: AccountContext + private let peerId: EnginePeer.Id + private let isMonoforum: Bool + private let panel = ComponentView() init(context: AccountContext, peerId: EnginePeer.Id, isMonoforum: Bool) { self.context = context + self.peerId = peerId self.isMonoforum = isMonoforum - self.selectedLineView = UIImageView() - - self.scrollView = ScrollView(frame: CGRect()) - self.scrollViewMask = UIImageView(image: generateGradientImage(size: CGSize(width: 8.0, height: 8.0), colors: [ - UIColor(white: 1.0, alpha: 0.0), - UIColor(white: 1.0, alpha: 1.0) - ], locations: [0.0, 1.0], direction: .horizontal)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 0)) - - self.scrollViewContainer = UIView() - super.init() - self.scrollView.delaysContentTouches = false - self.scrollView.canCancelContentTouches = true - self.scrollView.clipsToBounds = true - self.scrollView.contentInsetAdjustmentBehavior = .never - if #available(iOS 13.0, *) { - self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - } - self.scrollView.showsVerticalScrollIndicator = false - self.scrollView.showsHorizontalScrollIndicator = false - self.scrollView.alwaysBounceHorizontal = false - self.scrollView.alwaysBounceVertical = false - self.scrollView.scrollsToTop = false - self.scrollViewContainer.addSubview(self.scrollView) - self.scrollViewContainer.mask = self.scrollViewMask - - self.view.addSubview(self.scrollViewContainer) - - self.scrollView.addSubview(self.selectedLineView) - - self.scrollView.disablesInteractiveTransitionGestureRecognizer = true - - let threadListSignal: Signal = context.sharedContext.subscribeChatListData(context: context, location: isMonoforum ? .savedMessagesChats(peerId: peerId) : .forum(peerId: peerId)) - - self.itemsDisposable = (threadListSignal - |> deliverOnMainQueue).startStrict(next: { [weak self] chatList in - guard let self else { - return - } - self.items.removeAll() - - for item in chatList.items.reversed() { - self.items.append(Item(item: item)) - } - - self.update(transition: .immediate) - }) } deinit { - self.itemsDisposable?.dispose() } private func update(transition: ContainedViewLayoutTransition) { @@ -819,14 +558,6 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> LayoutResult { let params = Params(width: width, leftInset: leftInset, rightInset: rightInset, interfaceState: interfaceState) if self.params != params { - if self.params?.interfaceState.theme !== params.interfaceState.theme { - self.selectedLineView.image = generateImage(CGSize(width: 7.0, height: 4.0), rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - context.setFillColor(params.interfaceState.theme.rootController.navigationBar.accentTextColor.cgColor) - context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: size.width, height: size.width))) - })?.stretchableImage(withLeftCapWidth: 4, topCapHeight: 1) - } - self.params = params self.update(params: params, transition: transition) } @@ -841,14 +572,54 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } private func update(params: Params, transition: ContainedViewLayoutTransition) { - let hadItemViews = !self.itemViews.isEmpty + let panelHeight: CGFloat = 44.0 - var transition = transition - if !hadItemViews { - transition = .immediate + let panelFrame = CGRect(origin: CGPoint(), size: CGSize(width: params.width, height: panelHeight)) + let _ = self.panel.update( + transition: ComponentTransition(transition), + component: AnyComponent(ChatSideTopicsPanel( + context: self.context, + theme: params.interfaceState.theme, + strings: params.interfaceState.strings, + location: .top, + peerId: self.peerId, + isMonoforum: self.isMonoforum, + topicId: params.interfaceState.chatLocation.threadId, + controller: { [weak self] in + return self?.interfaceInteraction?.chatController() + }, + togglePanel: { [weak self] in + guard let self else { + return + } + self.interfaceInteraction?.toggleChatSidebarMode() + }, + updateTopicId: { [weak self] topicId, direction in + guard let self else { + return + } + self.interfaceInteraction?.updateChatLocationThread(topicId, direction ? .right : .left) + } + )), + environment: { + ChatSidePanelEnvironment(insets: UIEdgeInsets( + top: 0.0, + left: params.leftInset, + bottom: 0.0, + right: params.rightInset + )) + }, + containerSize: panelFrame.size + ) + if let panelView = self.panel.view { + if panelView.superview == nil { + panelView.disablesInteractiveTransitionGestureRecognizer = true + self.view.addSubview(panelView) + } + transition.updateFrame(view: panelView, frame: panelFrame) } - let panelHeight: CGFloat = 44.0 + /* let containerInsets = UIEdgeInsets(top: 0.0, left: params.leftInset + 16.0, bottom: 0.0, right: params.rightInset + 16.0) let itemSpacing: CGFloat = 24.0 @@ -1068,17 +839,24 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } else { self.appliedScrollToId = scrollToId } - } + }*/ } public func updateGlobalOffset(globalOffset: CGFloat, transition: ComponentTransition) { - if let tabItemView = self.tabItemView { - transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) + if let panelView = self.panel.view as? ChatSideTopicsPanel.View { + panelView.updateGlobalOffset(globalOffset: globalOffset, transition: transition) + //transition.setTransform(view: tabItemView, transform: CATransform3DMakeTranslation(0.0, -globalOffset, 0.0)) } } public func topicIndex(threadId: Int64?) -> Int? { - if let threadId { + if let panelView = self.panel.view as? ChatSideTopicsPanel.View { + return panelView.topicIndex(threadId: threadId) + } else { + return nil + } + + /*if let threadId { if let value = self.items.firstIndex(where: { item in if item.id == .chatList(PeerId(threadId)) { return true @@ -1094,6 +872,6 @@ final class ChatTopicListTitleAccessoryPanelNode: ChatTitleAccessoryPanelNode, C } } else { return 0 - } + }*/ } } diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift index dd14e47777..b05b3ca8eb 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputContextPanelNode.swift @@ -18,6 +18,7 @@ import PremiumUI import ChatControllerInteraction import ChatContextResultPeekContent import ChatInputContextPanelNode +import BatchVideoRendering private struct ChatContextResultStableId: Hashable { let result: ChatContextResult @@ -48,8 +49,8 @@ private struct HorizontalListContextResultsChatInputContextPanelEntry: Comparabl return lhs.index < rhs.index } - func item(context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { - return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, resultSelected: resultSelected) + func item(context: AccountContext, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> ListViewItem { + return HorizontalListContextResultsChatInputPanelItem(context: context, theme: self.theme, result: self.result, batchVideoContext: batchVideoContext, resultSelected: resultSelected) } } @@ -71,12 +72,12 @@ private final class HorizontalListContextResultsOpaqueState { } } -private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition { +private func preparedTransition(from fromEntries: [HorizontalListContextResultsChatInputContextPanelEntry], to toEntries: [HorizontalListContextResultsChatInputContextPanelEntry], hasMore: Bool, context: AccountContext, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) -> HorizontalListContextResultsChatInputContextPanelTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, resultSelected: resultSelected), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, batchVideoContext: batchVideoContext, resultSelected: resultSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, batchVideoContext: batchVideoContext, resultSelected: resultSelected), directionHint: nil) } return HorizontalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates, entryCount: toEntries.count, hasMore: hasMore) } @@ -93,6 +94,8 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont private var enqueuedTransitions: [(HorizontalListContextResultsChatInputContextPanelTransition, Bool)] = [] private var hasValidLayout = false + private let batchVideoContext: QueueLocalObject + override init(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, fontSize: PresentationFontSize, chatPresentationContext: ChatPresentationContext) { self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true @@ -108,6 +111,10 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont return strings.VoiceOver_ScrollStatus(row, count).string } + self.batchVideoContext = QueueLocalObject(queue: .mainQueue(), generate: { + return BatchVideoRenderingContext(context: context) + }) + super.init(context: context, theme: theme, strings: strings, fontSize: fontSize, chatPresentationContext: chatPresentationContext) self.isOpaque = false @@ -136,7 +143,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont self.listView.view.disablesInteractiveTransitionGestureRecognizer = true self.listView.view.disablesInteractiveKeyboardGestureRecognizer = true - self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point in + self.view.addGestureRecognizer(PeekControllerGestureRecognizer(contentAtPoint: { [weak self] point -> Signal<(UIView, CGRect, PeekControllerContent)?, NoError>? in if let strongSelf = self { let convertedPoint = strongSelf.listView.view.convert(point, from: strongSelf.view) @@ -183,7 +190,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont let controller = PremiumIntroScreen(context: strongSelf.context, source: .stickers) strongSelf.interfaceInteraction?.getNavigationController()?.pushViewController(controller) })) - } else { + } else if let batchVideoContext = strongSelf.batchVideoContext.unsafeGet() { var menuItems: [ContextMenuItem] = [] if case let .internalReference(internalReference) = item.result, let file = internalReference.file, file.isAnimated { menuItems.append(.action(ContextMenuActionItem(text: strongSelf.strings.Preview_SaveGif, icon: { theme in @@ -229,7 +236,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont f(.default) let _ = item.resultSelected(item.result, itemNode, itemNode.bounds) }))) - selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(account: item.context.account, contextResult: item.result, menu: menuItems)) + selectedItemNodeAndContent = (itemNode.view, itemNode.bounds, ChatContextResultPeekContent(context: item.context, contextResult: item.result, menu: menuItems, batchVideoContext: batchVideoContext)) } } } @@ -312,7 +319,7 @@ final class HorizontalListContextResultsChatInputContextPanelNode: ChatInputCont } let firstTime = self.currentEntries == nil - let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, resultSelected: { [weak self] result, node, rect in + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, context: self.context, batchVideoContext: self.batchVideoContext, resultSelected: { [weak self] result, node, rect in if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { return interfaceInteraction.sendContextResult(results, result, node, rect) } else { diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift index 94770716c2..4209c7fd69 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift @@ -15,20 +15,23 @@ import TelegramPresentationData import AccountContext import ShimmerEffect import SoftwareVideo -import MultiplexedVideoNode +import BatchVideoRendering +import GifVideoLayer final class HorizontalListContextResultsChatInputPanelItem: ListViewItem { let context: AccountContext let theme: PresentationTheme let result: ChatContextResult + let batchVideoContext: QueueLocalObject let resultSelected: (ChatContextResult, ASDisplayNode, CGRect) -> Bool let selectable: Bool = true - public init(context: AccountContext, theme: PresentationTheme, result: ChatContextResult, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) { + public init(context: AccountContext, theme: PresentationTheme, result: ChatContextResult, batchVideoContext: QueueLocalObject, resultSelected: @escaping (ChatContextResult, ASDisplayNode, CGRect) -> Bool) { self.context = context self.theme = theme self.result = result + self.batchVideoContext = batchVideoContext self.resultSelected = resultSelected } @@ -90,7 +93,7 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode private let imageNode: TransformImageNode private var animationNode: AnimatedStickerNode? private var placeholderNode: StickerShimmerEffectNode? - private var videoLayer: (SoftwareVideoThumbnailNode, SoftwareVideoLayerFrameManager, SampleBufferLayer)? + private var videoLayer: GifVideoLayer? private var currentImageResource: TelegramMediaResource? private var currentVideoFile: TelegramMediaFile? private var currentAnimatedStickerFile: TelegramMediaFile? @@ -103,58 +106,17 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode override var visibility: ListViewItemNodeVisibility { didSet { - switch visibility { - case .visible: - self.ticking = true - default: - self.ticking = false + switch self.visibility { + case .visible: + self.videoLayer?.shouldBeAnimating = true + case .none: + self.videoLayer?.shouldBeAnimating = false } } } private let timebase: CMTimebase - private var displayLink: CADisplayLink? - private var ticking: Bool = false { - didSet { - if self.ticking != oldValue { - if self.ticking { - class DisplayLinkProxy: NSObject { - weak var target: HorizontalListContextResultsChatInputPanelItemNode? - init(target: HorizontalListContextResultsChatInputPanelItemNode) { - self.target = target - } - - @objc func displayLinkEvent() { - self.target?.displayLinkEvent() - } - } - - let displayLink = CADisplayLink(target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.displayLinkEvent)) - self.displayLink = displayLink - displayLink.add(to: RunLoop.main, forMode: .common) - if #available(iOS 10.0, *) { - displayLink.preferredFramesPerSecond = 25 - } else { - displayLink.frameInterval = 2 - } - displayLink.isPaused = false - CMTimebaseSetRate(self.timebase, rate: 1.0) - } else if let displayLink = self.displayLink { - self.displayLink = nil - displayLink.isPaused = true - displayLink.invalidate() - CMTimebaseSetRate(self.timebase, rate: 0.0) - } - } - } - } - - private func displayLinkEvent() { - let timestamp = CMTimebaseGetTime(self.timebase).seconds - self.videoLayer?.1.tick(timestamp: timestamp) - } - init() { self.imageNodeBackground = ASDisplayNode() self.imageNodeBackground.isLayerBacked = true @@ -197,10 +159,6 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode } deinit { - if let displayLink = self.displayLink { - displayLink.isPaused = true - displayLink.invalidate() - } self.statusDisposable.dispose() self.fetchDisposable.dispose() } @@ -384,30 +342,25 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode } if updatedVideoFile { - if let (thumbnailLayer, _, layer) = strongSelf.videoLayer { + if let videoLayer = strongSelf.videoLayer { strongSelf.videoLayer = nil - thumbnailLayer.removeFromSupernode() - layer.layer.removeFromSuperlayer() + videoLayer.removeFromSuperlayer() } - if let videoFile = videoFile { - let thumbnailLayer = SoftwareVideoThumbnailNode(account: item.context.account, fileReference: .standalone(media: videoFile), synchronousLoad: synchronousLoads) - thumbnailLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) - strongSelf.addSubnode(thumbnailLayer) - let layerHolder = takeSampleBufferLayer() - layerHolder.layer.videoGravity = AVLayerVideoGravity.resizeAspectFill - layerHolder.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) - strongSelf.layer.addSublayer(layerHolder.layer) + if let videoFile, let batchVideoContext = item.batchVideoContext.unsafeGet() { + let videoLayer = GifVideoLayer( + context: item.context, + batchVideoContext: batchVideoContext, + userLocation: .other, + file: .standalone(media: videoFile), + synchronousLoad: synchronousLoads + ) + videoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill + videoLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 2.0, 0.0, 0.0, 1.0) + strongSelf.layer.addSublayer(videoLayer) - let manager = SoftwareVideoLayerFrameManager(account: item.context.account, userLocation: .other, userContentType: .other, fileReference: .standalone(media: videoFile), layerHolder: layerHolder) - strongSelf.videoLayer = (thumbnailLayer, manager, layerHolder) - thumbnailLayer.ready = { [weak thumbnailLayer, weak manager] in - if let strongSelf = self, let thumbnailLayer = thumbnailLayer, let manager = manager { - if strongSelf.videoLayer?.0 === thumbnailLayer && strongSelf.videoLayer?.1 === manager { - manager.start() - } - } - } + strongSelf.videoLayer = videoLayer + videoLayer.shouldBeAnimating = strongSelf.visibility != .none } } @@ -477,11 +430,9 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode strongSelf.statusNode.transitionToState(.none, completion: { }) } - if let (thumbnailLayer, _, layer) = strongSelf.videoLayer { - thumbnailLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) - thumbnailLayer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) - layer.layer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) - layer.layer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) + if let videoLayer = strongSelf.videoLayer { + videoLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: croppedImageDimensions.width, height: croppedImageDimensions.height)) + videoLayer.position = CGPoint(x: height / 2.0, y: (nodeLayout.contentSize.height - sideInset) / 2.0 + sideInset) } if let animationNode = strongSelf.animationNode { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 3d85b276be..dfdf148d15 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -85,6 +85,7 @@ import GiftStoreScreen import SendInviteLinkScreen import PostSuggestionsSettingsScreen import ForumSettingsScreen +import ForumCreateTopicScreen private final class AccountUserInterfaceInUseContext { let subscribers = Bag<(Bool) -> Void>() @@ -2609,6 +2610,25 @@ public final class SharedAccountContextImpl: SharedAccountContext { } } + public func makeEditForumTopicScreen(context: AccountContext, peerId: EnginePeer.Id, threadId: Int64, threadInfo: EngineMessageHistoryThread.Info, isHidden: Bool) -> ViewController { + let controller = ForumCreateTopicScreen(context: context, peerId: peerId, mode: .edit(threadId: threadId, threadInfo: threadInfo, isHidden: isHidden)) + controller.navigationPresentation = .modal + controller.completion = { [weak controller] title, fileId, _, isHidden in + let _ = (context.engine.peers.editForumChannelTopic(id: peerId, threadId: threadId, title: title, iconFileId: fileId) + |> deliverOnMainQueue).startStandalone(completed: { + controller?.dismiss() + }) + + if let isHidden { + let _ = (context.engine.peers.setForumChannelTopicHidden(id: peerId, threadId: threadId, isHidden: isHidden) + |> deliverOnMainQueue).startStandalone(completed: { + controller?.dismiss() + }) + } + } + return controller + } + private func mapIntroSource(source: PremiumIntroSource) -> PremiumSource { let mappedSource: PremiumSource switch source {