diff --git a/submodules/ItemListUI/Sources/ItemListControllerNode.swift b/submodules/ItemListUI/Sources/ItemListControllerNode.swift index bfa5769fbb..2d1059536e 100644 --- a/submodules/ItemListUI/Sources/ItemListControllerNode.swift +++ b/submodules/ItemListUI/Sources/ItemListControllerNode.swift @@ -232,7 +232,7 @@ public final class ItemListControllerNodeView: UITracingLayerView { weak var controller: ItemListController? } -open class ItemListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { +open class ItemListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { private weak var controller: ItemListController? private var _ready = ValuePromise() @@ -495,7 +495,7 @@ open class ItemListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { } return directions }, edgeWidth: .widthMultiplier(factor: 1.0 / 6.0, min: 22.0, max: 80.0)) - panRecognizer.delegate = self + panRecognizer.delegate = self.wrappedGestureRecognizerDelegate panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.panRecognizer = panRecognizer diff --git a/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift b/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift index 82c4fc84c0..a8a10279f6 100644 --- a/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift +++ b/submodules/ItemListUI/Sources/Items/ItemListSwitchItem.swift @@ -14,10 +14,16 @@ public enum ItemListSwitchItemNodeType { } public class ItemListSwitchItem: ListViewItem, ItemListItem { + public enum TextColor { + case primary + case accent + } + let presentationData: ItemListPresentationData let icon: UIImage? let title: String let text: String? + let textColor: TextColor let titleBadgeComponent: AnyComponent? let value: Bool let type: ItemListSwitchItemNodeType @@ -31,13 +37,15 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { let style: ItemListStyle let updated: (Bool) -> Void let activatedWhileDisabled: () -> Void + let action: (() -> Void)? public let tag: ItemListItemTag? - public init(presentationData: ItemListPresentationData, icon: UIImage? = nil, title: String, text: String? = nil, titleBadgeComponent: AnyComponent? = nil, value: Bool, type: ItemListSwitchItemNodeType = .regular, enableInteractiveChanges: Bool = true, enabled: Bool = true, displayLocked: Bool = false, disableLeadingInset: Bool = false, maximumNumberOfLines: Int = 1, noCorners: Bool = false, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Bool) -> Void, activatedWhileDisabled: @escaping () -> Void = {}, tag: ItemListItemTag? = nil) { + public init(presentationData: ItemListPresentationData, icon: UIImage? = nil, title: String, text: String? = nil, textColor: TextColor = .primary, titleBadgeComponent: AnyComponent? = nil, value: Bool, type: ItemListSwitchItemNodeType = .regular, enableInteractiveChanges: Bool = true, enabled: Bool = true, displayLocked: Bool = false, disableLeadingInset: Bool = false, maximumNumberOfLines: Int = 1, noCorners: Bool = false, sectionId: ItemListSectionId, style: ItemListStyle, updated: @escaping (Bool) -> Void, activatedWhileDisabled: @escaping () -> Void = {}, action: (() -> Void)? = nil, tag: ItemListItemTag? = nil) { self.presentationData = presentationData self.icon = icon self.title = title self.text = text + self.textColor = textColor self.titleBadgeComponent = titleBadgeComponent self.value = value self.type = type @@ -51,6 +59,7 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { self.style = style self.updated = updated self.activatedWhileDisabled = activatedWhileDisabled + self.action = action self.tag = tag } @@ -90,6 +99,17 @@ public class ItemListSwitchItem: ListViewItem, ItemListItem { } } } + + public var selectable: Bool { + return self.action != nil && self.enabled + } + + public func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + if self.enabled { + self.action?() + } + } } protocol ItemListSwitchNodeImpl { @@ -132,7 +152,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { private let iconNode: ASImageNode private let titleNode: TextNode - private let textNode: TextNode + private var textNode: TextNode? private var switchNode: ASDisplayNode & ItemListSwitchNodeImpl private let switchGestureNode: ASDisplayNode private var disabledOverlayNode: ASDisplayNode? @@ -168,11 +188,9 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { self.iconNode.displaysAsynchronously = false self.titleNode = TextNode() + self.titleNode.anchorPoint = CGPoint() self.titleNode.isUserInteractionEnabled = false - self.textNode = TextNode() - self.textNode.isUserInteractionEnabled = false - switch type { case .regular: self.switchNode = SwitchNode() @@ -190,7 +208,6 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { super.init(layerBacked: false, dynamicBounce: false) self.addSubnode(self.titleNode) - self.addSubnode(self.textNode) self.addSubnode(self.switchNode) self.addSubnode(self.switchGestureNode) self.addSubnode(self.activateArea) @@ -230,7 +247,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { let itemSeparatorColor: UIColor let titleFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize) - let textFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize * 13.0 / 17.0) + let textFont = Font.regular(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0) var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme !== item.presentationData.theme { @@ -274,11 +291,21 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor), backgroundColor: nil, maximumNumberOfLines: item.maximumNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 64.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: item.text ?? "", font: textFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 84.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) - contentSize.height = max(contentSize.height, titleLayout.size.height + topInset * 2.0) - if item.text != nil { + + var textLayoutAndApply: (TextNodeLayout, () -> TextNode)? + + if let text = item.text { + let textColor: UIColor + switch item.textColor { + case .primary: + textColor = item.presentationData.theme.list.itemSecondaryTextColor + case .accent: + textColor = item.presentationData.theme.list.itemAccentColor + } + let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: text, font: textFont, textColor: textColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - params.rightInset - 84.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) contentSize.height += -1.0 + textLayout.size.height + textLayoutAndApply = (textLayout, textApply) } if !item.enabled { @@ -294,6 +321,13 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { return (ListViewItemNodeLayout(contentSize: contentSize, insets: insets), { [weak self] animated in if let strongSelf = self { + let transition: ContainedViewLayoutTransition + if animated { + transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + strongSelf.item = item strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height)) @@ -309,7 +343,9 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.activateArea.accessibilityTraits = accessibilityTraits if let icon = item.icon { + var iconTransition = transition if strongSelf.iconNode.supernode == nil { + iconTransition = .immediate strongSelf.addSubnode(strongSelf.iconNode) } if updateIcon { @@ -321,19 +357,12 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } else { iconY = max(0.0, floor(topInset + titleLayout.size.height + 1.0 - icon.size.height * 0.5)) } - strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - icon.size.width) / 2.0), y: iconY), size: icon.size) + iconTransition.updateFrame(node: strongSelf.iconNode, frame: CGRect(origin: CGPoint(x: params.leftInset + floor((leftInset - params.leftInset - icon.size.width) / 2.0), y: iconY), size: icon.size)) } else if strongSelf.iconNode.supernode != nil { strongSelf.iconNode.image = nil strongSelf.iconNode.removeFromSupernode() } - let transition: ContainedViewLayoutTransition - if animated { - transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) - } else { - transition = .immediate - } - if let currentDisabledOverlayNode = currentDisabledOverlayNode { if currentDisabledOverlayNode != strongSelf.disabledOverlayNode { strongSelf.disabledOverlayNode = currentDisabledOverlayNode @@ -367,7 +396,6 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } let _ = titleApply() - let _ = textApply() switch item.style { case .plain: @@ -383,7 +411,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { if strongSelf.maskNode.supernode != nil { strongSelf.maskNode.removeFromSupernode() } - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight)) + transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: leftInset, y: contentSize.height - separatorHeight), size: CGSize(width: params.width - leftInset, height: separatorHeight))) case .blocks: if strongSelf.backgroundNode.supernode == nil { strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) @@ -421,15 +449,39 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil - strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight))) - strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) - strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)) - strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)) + transition.updateFrame(node: strongSelf.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))) + transition.updateFrame(node: strongSelf.maskNode, frame: strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)) + transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))) + transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height - separatorHeight), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))) } - strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: topInset), size: titleLayout.size) + let titleFrame = CGRect(origin: CGPoint(x: leftInset, y: topInset), size: titleLayout.size) + transition.updatePosition(node: strongSelf.titleNode, position: titleFrame.origin) + strongSelf.titleNode.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) - strongSelf.textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: strongSelf.titleNode.frame.maxY + 2.0), size: textLayout.size) + if let (textLayout, textApply) = textLayoutAndApply { + let textNode = textApply() + if strongSelf.textNode !== textNode { + strongSelf.textNode?.removeFromSupernode() + strongSelf.textNode = textNode + textNode.isUserInteractionEnabled = false + strongSelf.addSubnode(textNode) + + if transition.isAnimated { + textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + textNode.frame = CGRect(origin: CGPoint(x: leftInset, y: strongSelf.titleNode.frame.maxY + 2.0), size: textLayout.size) + } else if let textNode = strongSelf.textNode { + strongSelf.textNode = nil + if transition.isAnimated { + textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textNode] _ in + textNode?.removeFromSupernode() + }) + } else { + textNode.removeFromSupernode() + } + } if let switchView = strongSelf.switchNode.view as? UISwitch { if strongSelf.switchNode.bounds.size.width.isZero { @@ -437,7 +489,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { } let switchSize = switchView.bounds.size - strongSelf.switchNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - switchSize.width - 15.0, y: floor((contentSize.height - switchSize.height) / 2.0)), size: switchSize) + transition.updateFrame(node: strongSelf.switchNode, frame: CGRect(origin: CGPoint(x: params.width - params.rightInset - switchSize.width - 15.0, y: floor((contentSize.height - switchSize.height) / 2.0)), size: switchSize)) strongSelf.switchGestureNode.frame = strongSelf.switchNode.frame if switchView.isOn != item.value { switchView.setOn(item.value, animated: animated) @@ -447,6 +499,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { strongSelf.switchGestureNode.isHidden = item.enableInteractiveChanges && item.enabled if item.displayLocked { + var lockedIconTransition = transition var updateLockedIconImage = false if let _ = updatedTheme { updateLockedIconImage = true @@ -456,6 +509,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { if let current = strongSelf.lockedIconNode { lockedIconNode = current } else { + lockedIconTransition = .immediate updateLockedIconImage = true lockedIconNode = ASImageNode() strongSelf.lockedIconNode = lockedIconNode @@ -469,7 +523,7 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { let switchFrame = strongSelf.switchNode.frame if let icon = lockedIconNode.image { - lockedIconNode.frame = CGRect(origin: CGPoint(x: switchFrame.minX + 10.0 + UIScreenPixel, y: switchFrame.minY + 9.0), size: icon.size) + lockedIconTransition.updateFrame(node: lockedIconNode, frame: CGRect(origin: CGPoint(x: switchFrame.minX + 10.0 + UIScreenPixel, y: switchFrame.minY + 9.0), size: icon.size)) } } else if let lockedIconNode = strongSelf.lockedIconNode { strongSelf.lockedIconNode = nil @@ -492,17 +546,19 @@ public class ItemListSwitchItemNode: ListViewItemNode, ItemListItemNode { containerSize: contentSize ) if let view = componentView.view { + var titleBadgeTransition = transition if view.superview == nil { + titleBadgeTransition = .immediate strongSelf.view.addSubview(view) } - view.frame = CGRect(origin: CGPoint(x: strongSelf.titleNode.frame.maxX + 7.0, y: floor((contentSize.height - badgeSize.height) / 2.0)), size: badgeSize) + titleBadgeTransition.updateFrame(view: view, frame: CGRect(origin: CGPoint(x: strongSelf.titleNode.frame.maxX + 7.0, y: floor((contentSize.height - badgeSize.height) / 2.0)), size: badgeSize)) } } else if let componentView = strongSelf.titleBadgeComponentView { strongSelf.titleBadgeComponentView = nil componentView.view?.removeFromSuperview() } - strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: 44.0 + UIScreenPixel + UIScreenPixel)) + transition.updateFrame(node: strongSelf.highlightedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: layoutSize.height + UIScreenPixel + UIScreenPixel))) } }) } diff --git a/submodules/SettingsUI/Sources/Notifications/NotificationsAndSoundsController.swift b/submodules/SettingsUI/Sources/Notifications/NotificationsAndSoundsController.swift index cee54b34c3..290afde096 100644 --- a/submodules/SettingsUI/Sources/Notifications/NotificationsAndSoundsController.swift +++ b/submodules/SettingsUI/Sources/Notifications/NotificationsAndSoundsController.swift @@ -63,6 +63,7 @@ private final class NotificationsAndSoundsArguments { let suppressWarning: () -> Void let openPeerCategory: (NotificationsPeerCategory) -> Void + let openReactions: () -> Void let updateInAppSounds: (Bool) -> Void let updateInAppVibration: (Bool) -> Void @@ -80,7 +81,7 @@ private final class NotificationsAndSoundsArguments { let updateNotificationsFromAllAccounts: (Bool) -> Void - init(context: AccountContext, presentController: @escaping (ViewController, ViewControllerPresentationArguments?) -> Void, pushController: @escaping(ViewController)->Void, soundSelectionDisposable: MetaDisposable, authorizeNotifications: @escaping () -> Void, suppressWarning: @escaping () -> Void, openPeerCategory: @escaping (NotificationsPeerCategory) -> Void, updateInAppSounds: @escaping (Bool) -> Void, updateInAppVibration: @escaping (Bool) -> Void, updateInAppPreviews: @escaping (Bool) -> Void, updateDisplayNameOnLockscreen: @escaping (Bool) -> Void, updateIncludeTag: @escaping (CounterTagSettings, Bool) -> Void, updateTotalUnreadCountCategory: @escaping (Bool) -> Void, resetNotifications: @escaping () -> Void, openAppSettings: @escaping () -> Void, updateJoinedNotifications: @escaping (Bool) -> Void, updateNotificationsFromAllAccounts: @escaping (Bool) -> Void) { + init(context: AccountContext, presentController: @escaping (ViewController, ViewControllerPresentationArguments?) -> Void, pushController: @escaping(ViewController)->Void, soundSelectionDisposable: MetaDisposable, authorizeNotifications: @escaping () -> Void, suppressWarning: @escaping () -> Void, openPeerCategory: @escaping (NotificationsPeerCategory) -> Void, openReactions: @escaping () -> Void, updateInAppSounds: @escaping (Bool) -> Void, updateInAppVibration: @escaping (Bool) -> Void, updateInAppPreviews: @escaping (Bool) -> Void, updateDisplayNameOnLockscreen: @escaping (Bool) -> Void, updateIncludeTag: @escaping (CounterTagSettings, Bool) -> Void, updateTotalUnreadCountCategory: @escaping (Bool) -> Void, resetNotifications: @escaping () -> Void, openAppSettings: @escaping () -> Void, updateJoinedNotifications: @escaping (Bool) -> Void, updateNotificationsFromAllAccounts: @escaping (Bool) -> Void) { self.context = context self.presentController = presentController self.pushController = pushController @@ -88,6 +89,7 @@ private final class NotificationsAndSoundsArguments { self.authorizeNotifications = authorizeNotifications self.suppressWarning = suppressWarning self.openPeerCategory = openPeerCategory + self.openReactions = openReactions self.updateInAppSounds = updateInAppSounds self.updateInAppVibration = updateInAppVibration self.updateInAppPreviews = updateInAppPreviews @@ -145,6 +147,7 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { case groupChats(PresentationTheme, String, String, String) case channels(PresentationTheme, String, String, String) case stories(PresentationTheme, String, String, String) + case reactions(PresentationTheme, String, String, String) case inAppHeader(PresentationTheme, String) case inAppSounds(PresentationTheme, String, Bool) @@ -171,7 +174,7 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { return NotificationsAndSoundsSection.accounts.rawValue case .permissionInfo, .permissionEnable: return NotificationsAndSoundsSection.permission.rawValue - case .categoriesHeader, .privateChats, .groupChats, .channels, .stories: + case .categoriesHeader, .privateChats, .groupChats, .channels, .stories, .reactions: return NotificationsAndSoundsSection.categories.rawValue case .inAppHeader, .inAppSounds, .inAppVibrate, .inAppPreviews: return NotificationsAndSoundsSection.inApp.rawValue @@ -208,6 +211,8 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { return 8 case .stories: return 9 + case .reactions: + return 10 case .inAppHeader: return 14 case .inAppSounds: @@ -326,6 +331,12 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { } else { return false } + case let .reactions(lhsTheme, lhsTitle, lhsSubtitle, lhsLabel): + if case let .reactions(rhsTheme, rhsTitle, rhsSubtitle, rhsLabel) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsLabel == rhsLabel { + return true + } else { + return false + } case let .inAppHeader(lhsTheme, lhsText): if case let .inAppHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { return true @@ -451,9 +462,13 @@ private enum NotificationsAndSoundsEntry: ItemListNodeEntry { arguments.openPeerCategory(.channel) }) case let .stories(_, title, subtitle, label): - return NotificationsCategoryItemListItem(presentationData: presentationData, icon: UIImage(bundleImageName: "Settings/Menu/Stories"), title: title, subtitle: subtitle, label: label, sectionId: self.section, style: .blocks, action: { + return NotificationsCategoryItemListItem(presentationData: presentationData, icon: PresentationResourcesSettings.stories, title: title, subtitle: subtitle, label: label, sectionId: self.section, style: .blocks, action: { arguments.openPeerCategory(.stories) }) + case let .reactions(_, title, subtitle, label): + return NotificationsCategoryItemListItem(presentationData: presentationData, icon: PresentationResourcesSettings.reactions, title: title, subtitle: subtitle, label: label, sectionId: self.section, style: .blocks, action: { + arguments.openReactions() + }) case let .inAppHeader(_, text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .inAppSounds(_, text, value): @@ -564,6 +579,31 @@ private func notificationsAndSoundsEntries(authorizationStatus: AccessType, warn entries.append(.stories(presentationData.theme, presentationData.strings.Notifications_Stories, !exceptions.stories.isEmpty ? presentationData.strings.Notifications_CategoryExceptions(Int32(exceptions.stories.peerIds.count)) : "", storiesValue)) + //TODO:localize + var reactionsValue: String = "" + var hasReactionNotifications = false + switch globalSettings.reactionSettings.messages { + case .nobody: + break + default: + if !reactionsValue.isEmpty { + reactionsValue.append(", ") + } + hasReactionNotifications = true + reactionsValue.append("Messages") + } + switch globalSettings.reactionSettings.stories { + case .nobody: + break + default: + if !reactionsValue.isEmpty { + reactionsValue.append(", ") + } + hasReactionNotifications = true + reactionsValue.append("Stories") + } + entries.append(.reactions(presentationData.theme, "Reactions", reactionsValue, hasReactionNotifications ? "On" : "Off")) + entries.append(.inAppHeader(presentationData.theme, presentationData.strings.Notifications_InAppNotifications.uppercased())) entries.append(.inAppSounds(presentationData.theme, presentationData.strings.Notifications_InAppNotificationsSounds, inAppSettings.playSounds)) entries.append(.inAppVibrate(presentationData.theme, presentationData.strings.Notifications_InAppNotificationsVibrate, inAppSettings.vibrate)) @@ -655,6 +695,10 @@ public func notificationsAndSoundsController(context: AccountContext, exceptions }) }, focusOnItemTag: nil)) }) + }, openReactions: { + pushControllerImpl?(reactionNotificationSettingsController( + context: context + )) }, updateInAppSounds: { value in let _ = updateInAppNotificationSettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in var settings = settings diff --git a/submodules/SettingsUI/Sources/NotificationsPeerCategoryController.swift b/submodules/SettingsUI/Sources/NotificationsPeerCategoryController.swift index 5abe4912b5..3ac514aa21 100644 --- a/submodules/SettingsUI/Sources/NotificationsPeerCategoryController.swift +++ b/submodules/SettingsUI/Sources/NotificationsPeerCategoryController.swift @@ -92,6 +92,7 @@ public enum NotificationsPeerCategoryEntryTag: ItemListItemTag { private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { enum StableId: Hashable { + case enableHeader case enable case enableImportant case importantInfo @@ -104,6 +105,7 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { case removeAllExceptions } + case enableHeader(String) case enable(PresentationTheme, String, Bool) case enableImportant(PresentationTheme, String, Bool) case importantInfo(PresentationTheme, String) @@ -118,7 +120,7 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { var section: ItemListSectionId { switch self { - case .enable, .enableImportant, .importantInfo: + case .enableHeader, .enable, .enableImportant, .importantInfo: return NotificationsPeerCategorySection.enable.rawValue case .optionsHeader, .previews, .sound: return NotificationsPeerCategorySection.options.rawValue @@ -129,6 +131,8 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { var stableId: StableId { switch self { + case .enableHeader: + return .enableHeader case .enable: return .enable case .enableImportant: @@ -154,6 +158,8 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { var sortIndex: Int32 { switch self { + case .enableHeader: + return -1 case .enable: return 0 case .enableImportant: @@ -192,6 +198,12 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { static func ==(lhs: NotificationsPeerCategoryEntry, rhs: NotificationsPeerCategoryEntry) -> Bool { switch lhs { + case let .enableHeader(text): + if case .enableHeader(text) = rhs { + return true + } else { + return false + } case let .enable(lhsTheme, lhsText, lhsValue): if case let .enable(rhsTheme, rhsText, rhsValue) = rhs, lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { return true @@ -263,6 +275,8 @@ private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { let arguments = arguments as! NotificationsPeerCategoryControllerArguments switch self { + case let .enableHeader(text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .enable(_, text, value): return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in arguments.updateEnabled(updatedValue) @@ -330,6 +344,9 @@ private func notificationsPeerCategoryEntries(category: NotificationsPeerCategor } if case .stories = category { + //TODO:localize + entries.append(.enableHeader("NOTIFY ME ABOUT...")) + var allEnabled = false var importantEnabled = false @@ -345,16 +362,16 @@ private func notificationsPeerCategoryEntries(category: NotificationsPeerCategor importantEnabled = true } - entries.append(.enable(presentationData.theme, presentationData.strings.NotificationSettings_Stories_ShowAll, allEnabled)) + entries.append(.enable(presentationData.theme, "New Stories", allEnabled)) if !allEnabled { - entries.append(.enableImportant(presentationData.theme, presentationData.strings.NotificationSettings_Stories_ShowImportant, importantEnabled)) + entries.append(.enableImportant(presentationData.theme, "Important Stories", importantEnabled)) entries.append(.importantInfo(presentationData.theme, presentationData.strings.NotificationSettings_Stories_ShowImportantFooter)) } if notificationSettings.enabled || !notificationExceptions.isEmpty { entries.append(.optionsHeader(presentationData.theme, presentationData.strings.Notifications_Options.uppercased())) - entries.append(.previews(presentationData.theme, presentationData.strings.NotificationSettings_Stories_DisplayAuthorName, notificationSettings.storySettings.hideSender != .hide)) + entries.append(.previews(presentationData.theme, "Show Sender's Name", notificationSettings.storySettings.hideSender != .hide)) entries.append(.sound(presentationData.theme, presentationData.strings.Notifications_MessageNotificationsSound, localizedPeerNotificationSoundString(strings: presentationData.strings, notificationSoundList: notificationSoundList, sound: filteredGlobalSound(notificationSettings.storySettings.sound)), filteredGlobalSound(notificationSettings.storySettings.sound))) } } else { diff --git a/submodules/SettingsUI/Sources/ReactionNotificationSettingsController.swift b/submodules/SettingsUI/Sources/ReactionNotificationSettingsController.swift new file mode 100644 index 0000000000..1e5fbe6aa9 --- /dev/null +++ b/submodules/SettingsUI/Sources/ReactionNotificationSettingsController.swift @@ -0,0 +1,418 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import TelegramUIPreferences +import DeviceAccess +import ItemListUI +import PresentationDataUtils +import AccountContext +import AlertUI +import PresentationDataUtils +import TelegramNotices +import NotificationSoundSelectionUI +import TelegramStringFormatting + +private final class ReactionNotificationSettingsControllerArguments { + let context: AccountContext + let soundSelectionDisposable: MetaDisposable + + let openMessages: () -> Void + let openStories: () -> Void + let toggleMessages: (Bool) -> Void + let toggleStories: (Bool) -> Void + let updatePreviews: (Bool) -> Void + let openSound: (PeerMessageSound) -> Void + + init( + context: AccountContext, + soundSelectionDisposable: MetaDisposable, + openMessages: @escaping () -> Void, + openStories: @escaping () -> Void, + toggleMessages: @escaping (Bool) -> Void, + toggleStories: @escaping (Bool) -> Void, + updatePreviews: @escaping (Bool) -> Void, + openSound: @escaping (PeerMessageSound) -> Void + ) { + self.context = context + self.soundSelectionDisposable = soundSelectionDisposable + self.openMessages = openMessages + self.openStories = openStories + self.toggleMessages = toggleMessages + self.toggleStories = toggleStories + self.updatePreviews = updatePreviews + self.openSound = openSound + } +} + +private enum ReactionNotificationSettingsSection: Int32 { + case categories + case options +} + +private enum ReactionNotificationSettingsEntry: ItemListNodeEntry { + enum StableId: Hashable { + case categoriesHeader + case messages + case stories + case optionsHeader + case previews + case sound + } + + case categoriesHeader(String) + case messages(title: String, text: String?, value: Bool) + case stories(title: String, text: String?, value: Bool) + + case optionsHeader(String) + case previews(String, Bool) + case sound(String, String, PeerMessageSound) + + var section: ItemListSectionId { + switch self { + case .categoriesHeader, .messages, .stories: + return ReactionNotificationSettingsSection.categories.rawValue + case .optionsHeader, .previews, .sound: + return ReactionNotificationSettingsSection.options.rawValue + } + } + + var stableId: StableId { + switch self { + case .categoriesHeader: + return .categoriesHeader + case .messages: + return .messages + case .stories: + return .stories + case .optionsHeader: + return .optionsHeader + case .previews: + return .previews + case .sound: + return .sound + } + } + + var sortIndex: Int32 { + switch self { + case .categoriesHeader: + return 0 + case .messages: + return 1 + case .stories: + return 2 + case .optionsHeader: + return 3 + case .previews: + return 4 + case .sound: + return 5 + } + } + + static func ==(lhs: ReactionNotificationSettingsEntry, rhs: ReactionNotificationSettingsEntry) -> Bool { + switch lhs { + case let .categoriesHeader(lhsText): + if case let .categoriesHeader(rhsText) = rhs, lhsText == rhsText { + return true + } else { + return false + } + case let .messages(title, text, value): + if case .messages(title, text, value) = rhs { + return true + } else { + return false + } + case let .stories(title, text, value): + if case .stories(title, text, value) = rhs { + return true + } else { + return false + } + case let .optionsHeader(lhsText): + if case let .optionsHeader(rhsText) = rhs, lhsText == rhsText { + return true + } else { + return false + } + case let .previews(lhsText, lhsValue): + if case let .previews(rhsText, rhsValue) = rhs, lhsText == rhsText, lhsValue == rhsValue { + return true + } else { + return false + } + case let .sound(lhsText, lhsValue, lhsSound): + if case let .sound(rhsText, rhsValue, rhsSound) = rhs, lhsText == rhsText, lhsValue == rhsValue, lhsSound == rhsSound { + return true + } else { + return false + } + } + } + + static func <(lhs: ReactionNotificationSettingsEntry, rhs: ReactionNotificationSettingsEntry) -> Bool { + return lhs.sortIndex < rhs.sortIndex + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! ReactionNotificationSettingsControllerArguments + switch self { + case let .categoriesHeader(text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .messages(title, text, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, text: text, textColor: .accent, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleMessages(value) + }, action: { + arguments.openMessages() + }) + case let .stories(title, text, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, text: text, textColor: .accent, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleStories(value) + }, action: { + arguments.openStories() + }) + case let .optionsHeader(text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .previews(text, value): + return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.updatePreviews(value) + }) + case let .sound(text, value, sound): + return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, labelStyle: .text, sectionId: self.section, style: .blocks, disclosureStyle: .arrow, action: { + arguments.openSound(sound) + }, tag: self.tag) + } + } +} + +private func filteredGlobalSound(_ sound: PeerMessageSound) -> PeerMessageSound { + if case .default = sound { + return defaultCloudPeerNotificationSound + } else { + return sound + } +} + +private func reactionNotificationSettingsEntries( + globalSettings: GlobalNotificationSettingsSet, + state: ReactionNotificationSettingsState, + presentationData: PresentationData, + notificationSoundList: NotificationSoundList? +) -> [ReactionNotificationSettingsEntry] { + var entries: [ReactionNotificationSettingsEntry] = [] + + //TODO:localize + entries.append(.categoriesHeader("NOTIFY ME ABOUT...")) + + let messagesText: String? + let messagesValue: Bool + switch globalSettings.reactionSettings.messages { + case .nobody: + messagesText = nil + messagesValue = false + case .contacts: + messagesText = "From My Contacts" + messagesValue = true + case .everyone: + messagesText = "From Everyone" + messagesValue = true + } + + let storiesText: String? + let storiesValue: Bool + switch globalSettings.reactionSettings.stories { + case .nobody: + storiesText = nil + storiesValue = false + case .contacts: + storiesText = "From My Contacts" + storiesValue = true + case .everyone: + storiesText = "From Everyone" + storiesValue = true + } + + entries.append(.messages(title: "Reactions to my Messages", text: messagesText, value: messagesValue)) + entries.append(.stories(title: "Reactions to my Stories", text: storiesText, value: storiesValue)) + + if messagesValue || storiesValue { + entries.append(.optionsHeader(presentationData.strings.Notifications_Options.uppercased())) + + //TODO:localize + entries.append(.previews("Show Sender's Name", globalSettings.reactionSettings.hideSender != .hide)) + entries.append(.sound(presentationData.strings.Notifications_MessageNotificationsSound, localizedPeerNotificationSoundString(strings: presentationData.strings, notificationSoundList: notificationSoundList, sound: filteredGlobalSound(globalSettings.reactionSettings.sound)), filteredGlobalSound(globalSettings.reactionSettings.sound))) + } + + return entries +} + +private struct ReactionNotificationSettingsState: Equatable { + init() { + } +} + +public func reactionNotificationSettingsController( + context: AccountContext +) -> ViewController { + var presentControllerImpl: ((ViewController, Any?) -> Void)? + var pushControllerImpl: ((ViewController) -> Void)? + + let stateValue = Atomic(value: ReactionNotificationSettingsState()) + let statePromise: ValuePromise = ValuePromise(ignoreRepeated: true) + + statePromise.set(stateValue.with { $0 }) + + let updateState: ((ReactionNotificationSettingsState) -> ReactionNotificationSettingsState) -> Void = { f in + let result = stateValue.modify { f($0) } + statePromise.set(result) + } + let _ = updateState + + let openCategory: (Bool) -> Void = { isMessages in + //TODO:localize + let text: String + if isMessages { + text = "Notify about reactions to my messages from" + } else { + text = "Notify about reactions to my stories from" + } + + let presentationData = context.sharedContext.currentPresentationData.with { $0 } + let actionSheet = ActionSheetController(presentationData: presentationData) + actionSheet.setItemGroups([ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: text), + ActionSheetButtonItem(title: "Everyone", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + if isMessages { + settings.reactionSettings.messages = .everyone + } else { + settings.reactionSettings.stories = .everyone + } + return settings + }).start() + }), + ActionSheetButtonItem(title: "My Contacts", color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + if isMessages { + settings.reactionSettings.messages = .contacts + } else { + settings.reactionSettings.stories = .contacts + } + return settings + }).start() + }) + ]), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ])]) + presentControllerImpl?(actionSheet, nil) + } + + let arguments = ReactionNotificationSettingsControllerArguments( + context: context, + soundSelectionDisposable: MetaDisposable(), + openMessages: { + openCategory(true) + }, + openStories: { + openCategory(false) + }, + toggleMessages: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + if value { + settings.reactionSettings.messages = .contacts + } else { + settings.reactionSettings.messages = .nobody + } + return settings + }).start() + }, + toggleStories: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + if value { + settings.reactionSettings.stories = .contacts + } else { + settings.reactionSettings.stories = .nobody + } + return settings + }).start() + }, + updatePreviews: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + settings.reactionSettings.hideSender = value ? .show : .hide + return settings + }).start() + }, openSound: { sound in + let controller = notificationSoundSelectionController(context: context, isModal: true, currentSound: sound, defaultSound: nil, completion: { value in + let _ = updateGlobalNotificationSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + settings.reactionSettings.sound = value + return settings + }).start() + }) + pushControllerImpl?(controller) + } + ) + + let preferences = context.account.postbox.preferencesView(keys: [PreferencesKeys.globalNotifications]) + + let signal = combineLatest(queue: .mainQueue(), + context.sharedContext.presentationData, + context.engine.peers.notificationSoundList(), + preferences, + statePromise.get() + ) + |> map { presentationData, notificationSoundList, preferencesView, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let viewSettings: GlobalNotificationSettingsSet + if let settings = preferencesView.values[PreferencesKeys.globalNotifications]?.get(GlobalNotificationSettings.self) { + viewSettings = settings.effective + } else { + viewSettings = GlobalNotificationSettingsSet.defaultSettings + } + + let entries = reactionNotificationSettingsEntries( + globalSettings: viewSettings, + state: state, + presentationData: presentationData, + notificationSoundList: notificationSoundList + ) + + let leftNavigationButton: ItemListNavigationButton? + let rightNavigationButton: ItemListNavigationButton? + + leftNavigationButton = nil + rightNavigationButton = nil + + //TODO:localize + let title: String = "Reactions" + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + presentControllerImpl = { [weak controller] c, a in + controller?.present(c, in: .window(.root), with: a) + } + pushControllerImpl = { [weak controller] c in + (controller?.navigationController as? NavigationController)?.pushViewController(c) + } + return controller +} diff --git a/submodules/TelegramCore/Sources/State/ManagedGlobalNotificationSettings.swift b/submodules/TelegramCore/Sources/State/ManagedGlobalNotificationSettings.swift index e1463d6e50..7fc67e6704 100644 --- a/submodules/TelegramCore/Sources/State/ManagedGlobalNotificationSettings.swift +++ b/submodules/TelegramCore/Sources/State/ManagedGlobalNotificationSettings.swift @@ -270,7 +270,9 @@ private func fetchedNotificationSettings(network: Network) -> Signal PeerReactionNotificationSettings { + return PeerReactionNotificationSettings( + messages: self.messages, + stories: self.stories, + hideSender: self.hideSender, + sound: self.sound._asMessageSound() + ) + } +} + public extension EngineGlobalNotificationSettings { init(_ globalNotificationSettings: GlobalNotificationSettingsSet) { self.init( privateChats: CategorySettings(globalNotificationSettings.privateChats), groupChats: CategorySettings(globalNotificationSettings.groupChats), channels: CategorySettings(globalNotificationSettings.channels), + reactionSettings: ReactionSettings(globalNotificationSettings.reactionSettings), contactsJoined: globalNotificationSettings.contactsJoined ) } @@ -647,6 +685,7 @@ public extension EngineGlobalNotificationSettings { privateChats: self.privateChats._asMessageNotificationSettings(), groupChats: self.groupChats._asMessageNotificationSettings(), channels: self.channels._asMessageNotificationSettings(), + reactionSettings: self.reactionSettings._asReactionSettings(), contactsJoined: self.contactsJoined ) } diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift index 4ca6bf6921..0e48756da2 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift @@ -26,7 +26,7 @@ private func addRoundedRectPath(context: CGContext, rect: CGRect, radius: CGFloa context.restoreGState() } -private func renderIcon(name: String, backgroundColors: [UIColor]? = nil) -> UIImage? { +private func renderIcon(name: String, scaleFactor: CGFloat = 1.0, backgroundColors: [UIColor]? = nil) -> UIImage? { return generateImage(CGSize(width: 29.0, height: 29.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) @@ -44,12 +44,14 @@ private func renderIcon(name: String, backgroundColors: [UIColor]? = nil) -> UII context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: size.height), end: CGPoint(x: 0.0, y: 0.0), options: CGGradientDrawingOptions()) context.resetClip() - if let image = generateTintedImage(image: UIImage(bundleImageName: name), color: .white)?.cgImage { - context.draw(image, in: bounds) + if let image = generateTintedImage(image: UIImage(bundleImageName: name), color: .white), let cgImage = image.cgImage { + let imageSize = CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor) + context.draw(cgImage, in: CGRect(origin: CGPoint(x: (bounds.width - imageSize.width) * 0.5, y: (bounds.height - imageSize.height) * 0.5), size: imageSize)) } } else { - if let image = UIImage(bundleImageName: name)?.cgImage { - context.draw(image, in: bounds) + if let image = UIImage(bundleImageName: name), let cgImage = image.cgImage { + let imageSize = CGSize(width: image.size.width * scaleFactor, height: image.size.height * scaleFactor) + context.draw(cgImage, in: CGRect(origin: CGPoint(x: (bounds.width - imageSize.width) * 0.5, y: (bounds.height - imageSize.height) * 0.5), size: imageSize)) } } }) @@ -70,10 +72,11 @@ public struct PresentationResourcesSettings { public static let language = renderIcon(name: "Settings/Menu/Language") public static let deleteAccount = renderIcon(name: "Chat/Info/GroupRemovedIcon") public static let powerSaving = renderIcon(name: "Settings/Menu/PowerSaving") - public static let stories = renderIcon(name: "Settings/Menu/Stories") + public static let stories = renderIcon(name: "Premium/Perk/Stories", scaleFactor: 0.97, backgroundColors: [UIColor(rgb: 0x5856D6)]) public static let premiumGift = renderIcon(name: "Settings/Menu/Gift") public static let business = renderIcon(name: "Settings/Menu/Business", backgroundColors: [UIColor(rgb: 0xA95CE3), UIColor(rgb: 0xF16B80)]) public static let myProfile = renderIcon(name: "Settings/Menu/Profile") + public static let reactions = renderIcon(name: "Settings/Menu/Reactions") public static let premium = generateImage(CGSize(width: 29.0, height: 29.0), contextGenerator: { size, context in let bounds = CGRect(origin: CGPoint(), size: size)