From 309a8b112b2ad69a153c2425df85522d4b0874bf Mon Sep 17 00:00:00 2001 From: Ali <> Date: Tue, 28 Jan 2020 16:29:00 +0400 Subject: [PATCH] Experimental chat list filtering --- .../InAppNotificationSettings.swift | 23 +- NotificationService/Namespaces.swift | 42 +- NotificationService/Sync.swift | 12 +- .../Sources/ChatListController.swift | 41 +- .../ChatListFilterPresetController.swift | 387 +++++++++++++++ .../ChatListFilterPresetListController.swift | 212 +++++++++ .../ChatListFilterPresetListItem.swift | 439 ++++++++++++++++++ .../Sources/Node/ChatListNode.swift | 9 +- .../Sources/Node/ChatListNodeLocation.swift | 72 +-- .../Sources/Node/ChatListViewTransition.swift | 3 +- .../TabBarChatListFilterController.swift | 316 +++++++++++-- submodules/Postbox/Sources/ChatListView.swift | 12 +- .../Sources/MessageHistoryViewState.swift | 11 +- submodules/Postbox/Sources/Postbox.swift | 20 +- .../NotificationsAndSounds.swift | 62 ++- submodules/SyncCore/Sources/Namespaces.swift | 40 +- .../StandaloneAccountTransaction.swift | 33 +- .../Sources/AccountViewTracker.swift | 4 +- .../TelegramUI/DeclareEncodables.swift | 1 + .../Sources/ChatListFilterSettings.swift | 127 +++++ .../Sources/InAppNotificationSettings.swift | 24 +- .../Sources/PostboxKeys.swift | 2 + 22 files changed, 1752 insertions(+), 140 deletions(-) create mode 100644 submodules/ChatListUI/Sources/ChatListFilterPresetController.swift create mode 100644 submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift create mode 100644 submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift create mode 100644 submodules/TelegramUIPreferences/Sources/ChatListFilterSettings.swift diff --git a/NotificationService/InAppNotificationSettings.swift b/NotificationService/InAppNotificationSettings.swift index a5f3ce36cc..ea6e849c8f 100644 --- a/NotificationService/InAppNotificationSettings.swift +++ b/NotificationService/InAppNotificationSettings.swift @@ -40,7 +40,7 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { public var displayNotificationsFromAllAccounts: Bool public static var defaultSettings: InAppNotificationSettings { - return InAppNotificationSettings(playSounds: true, vibrate: false, displayPreviews: true, totalUnreadCountDisplayStyle: .filtered, totalUnreadCountDisplayCategory: .messages, totalUnreadCountIncludeTags: [.regularChatsAndPrivateGroups], displayNameOnLockscreen: true, displayNotificationsFromAllAccounts: true) + return InAppNotificationSettings(playSounds: true, vibrate: false, displayPreviews: true, totalUnreadCountDisplayStyle: .filtered, totalUnreadCountDisplayCategory: .messages, totalUnreadCountIncludeTags: [.privateChat, .secretChat, .bot, .privateGroup], displayNameOnLockscreen: true, displayNotificationsFromAllAccounts: true) } public init(playSounds: Bool, vibrate: Bool, displayPreviews: Bool, totalUnreadCountDisplayStyle: TotalUnreadCountDisplayStyle, totalUnreadCountDisplayCategory: TotalUnreadCountDisplayCategory, totalUnreadCountIncludeTags: PeerSummaryCounterTags, displayNameOnLockscreen: Bool, displayNotificationsFromAllAccounts: Bool) { @@ -60,10 +60,25 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { self.displayPreviews = decoder.decodeInt32ForKey("p", orElse: 0) != 0 self.totalUnreadCountDisplayStyle = TotalUnreadCountDisplayStyle(rawValue: decoder.decodeInt32ForKey("cds", orElse: 0)) ?? .filtered self.totalUnreadCountDisplayCategory = TotalUnreadCountDisplayCategory(rawValue: decoder.decodeInt32ForKey("totalUnreadCountDisplayCategory", orElse: 1)) ?? .messages - if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags") { + if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags_2") { self.totalUnreadCountIncludeTags = PeerSummaryCounterTags(rawValue: value) + } else if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags") { + var resultTags: PeerSummaryCounterTags = [] + for legacyTag in LegacyPeerSummaryCounterTags(rawValue: value) { + if legacyTag == .regularChatsAndPrivateGroups { + resultTags.insert(.privateChat) + resultTags.insert(.secretChat) + resultTags.insert(.bot) + resultTags.insert(.privateGroup) + } else if legacyTag == .publicGroups { + resultTags.insert(.publicGroup) + } else if legacyTag == .channels { + resultTags.insert(.channel) + } + } + self.totalUnreadCountIncludeTags = resultTags } else { - self.totalUnreadCountIncludeTags = [.regularChatsAndPrivateGroups] + self.totalUnreadCountIncludeTags = [.privateChat, .secretChat, .bot, .privateGroup] } self.displayNameOnLockscreen = decoder.decodeInt32ForKey("displayNameOnLockscreen", orElse: 1) != 0 self.displayNotificationsFromAllAccounts = decoder.decodeInt32ForKey("displayNotificationsFromAllAccounts", orElse: 1) != 0 @@ -75,7 +90,7 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { encoder.encodeInt32(self.displayPreviews ? 1 : 0, forKey: "p") encoder.encodeInt32(self.totalUnreadCountDisplayStyle.rawValue, forKey: "cds") encoder.encodeInt32(self.totalUnreadCountDisplayCategory.rawValue, forKey: "totalUnreadCountDisplayCategory") - encoder.encodeInt32(self.totalUnreadCountIncludeTags.rawValue, forKey: "totalUnreadCountIncludeTags") + encoder.encodeInt32(self.totalUnreadCountIncludeTags.rawValue, forKey: "totalUnreadCountIncludeTags_2") encoder.encodeInt32(self.displayNameOnLockscreen ? 1 : 0, forKey: "displayNameOnLockscreen") encoder.encodeInt32(self.displayNotificationsFromAllAccounts ? 1 : 0, forKey: "displayNotificationsFromAllAccounts") } diff --git a/NotificationService/Namespaces.swift b/NotificationService/Namespaces.swift index b60b7ff89a..d8aed1ec40 100644 --- a/NotificationService/Namespaces.swift +++ b/NotificationService/Namespaces.swift @@ -1,9 +1,43 @@ import PostboxDataTypes +struct LegacyPeerSummaryCounterTags: OptionSet, Sequence, Hashable { + var rawValue: Int32 + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + static let regularChatsAndPrivateGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 0) + static let publicGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 1) + static let channels = LegacyPeerSummaryCounterTags(rawValue: 1 << 2) + + public func makeIterator() -> AnyIterator { + var index = 0 + return AnyIterator { () -> LegacyPeerSummaryCounterTags? in + while index < 31 { + let currentTags = self.rawValue >> UInt32(index) + let tag = LegacyPeerSummaryCounterTags(rawValue: 1 << UInt32(index)) + index += 1 + if currentTags == 0 { + break + } + + if (currentTags & 1) != 0 { + return tag + } + } + return nil + } + } +} + extension PeerSummaryCounterTags { - static let regularChatsAndPrivateGroups = PeerSummaryCounterTags(rawValue: 1 << 0) - static let publicGroups = PeerSummaryCounterTags(rawValue: 1 << 1) - static let channels = PeerSummaryCounterTags(rawValue: 1 << 2) + static let privateChat = PeerSummaryCounterTags(rawValue: 1 << 3) + static let secretChat = PeerSummaryCounterTags(rawValue: 1 << 4) + static let privateGroup = PeerSummaryCounterTags(rawValue: 1 << 5) + static let bot = PeerSummaryCounterTags(rawValue: 1 << 6) + static let channel = PeerSummaryCounterTags(rawValue: 1 << 7) + static let publicGroup = PeerSummaryCounterTags(rawValue: 1 << 8) } struct Namespaces { @@ -17,4 +51,4 @@ struct Namespaces { static let CloudChannel: Int32 = 2 static let SecretChat: Int32 = 3 } -} \ No newline at end of file +} diff --git a/NotificationService/Sync.swift b/NotificationService/Sync.swift index 21a93f439b..26fdd95d42 100644 --- a/NotificationService/Sync.swift +++ b/NotificationService/Sync.swift @@ -94,19 +94,21 @@ enum SyncProviderImpl { if let channel = peerTable.get(peerId) as? TelegramChannel { switch channel.info { case .broadcast: - tag = .channels + tag = .channel case .group: if channel.username != nil { - tag = .publicGroups + tag = .publicGroup } else { - tag = .regularChatsAndPrivateGroups + tag = .privateGroup } } } else { - tag = .channels + tag = .channel } + } else if peerId.namespace == Namespaces.Peer.CloudGroup { + tag = .privateGroup } else { - tag = .regularChatsAndPrivateGroups + tag = .privateChat } var totalCount: Int32 = -1 diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 44110de78e..e86ca39607 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -261,11 +261,28 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController, } if !self.hideNetworkActivityStatus { - self.titleDisposable = combineLatest(queue: .mainQueue(), context.account.networkState, hasProxy, passcode, self.chatListDisplayNode.chatListNode.state).start(next: { [weak self] networkState, proxy, passcode, state in + self.titleDisposable = combineLatest(queue: .mainQueue(), + context.account.networkState, + hasProxy, + passcode, + self.chatListDisplayNode.chatListNode.state, + self.chatListDisplayNode.chatListNode.chatListFilterSignal + ).start(next: { [weak self] networkState, proxy, passcode, state, chatListFilter in if let strongSelf = self { let defaultTitle: String if case .root = strongSelf.groupId { - defaultTitle = strongSelf.presentationData.strings.DialogList_Title + if let chatListFilter = chatListFilter { + let title: String + switch chatListFilter.name { + case .unread: + title = "Unread" + case let .custom(value): + title = value + } + defaultTitle = title + } else { + defaultTitle = strongSelf.presentationData.strings.DialogList_Title + } } else { defaultTitle = strongSelf.presentationData.strings.ChatList_ArchivedChatsTitle } @@ -1793,13 +1810,27 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController, public func presentTabBarPreviewingController(sourceNodes: [ASDisplayNode]) { if self.isNodeLoaded { - let controller = TabBarChatListFilterController(context: self.context, sourceNodes: sourceNodes, currentFilter: self.chatListDisplayNode.chatListNode.chatListFilter, updateFilter: { [weak self] value in + let _ = (self.context.account.postbox.transaction { transaction -> [ChatListFilterPreset] in + let settings = transaction.getPreferencesEntry(key: ApplicationSpecificPreferencesKeys.chatListFilterSettings) as? ChatListFilterSettings ?? ChatListFilterSettings.default + return settings.presets + } + |> deliverOnMainQueue).start(next: { [weak self] presetList in guard let strongSelf = self else { return } - strongSelf.chatListDisplayNode.chatListNode.chatListFilter = value + let controller = TabBarChatListFilterController(context: strongSelf.context, sourceNodes: sourceNodes, presetList: presetList, currentPreset: strongSelf.chatListDisplayNode.chatListNode.chatListFilter, setup: { + guard let strongSelf = self else { + return + } + strongSelf.push(chatListFilterPresetListController(context: strongSelf.context)) + }, updatePreset: { value in + guard let strongSelf = self else { + return + } + strongSelf.chatListDisplayNode.chatListNode.chatListFilter = value + }) + strongSelf.context.sharedContext.mainWindow?.present(controller, on: .root) }) - self.context.sharedContext.mainWindow?.present(controller, on: .root) } } diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift new file mode 100644 index 0000000000..4599784653 --- /dev/null +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetController.swift @@ -0,0 +1,387 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import SyncCore +import TelegramPresentationData +import ItemListUI +import PresentationDataUtils +import AccountContext +import TelegramUIPreferences +import ItemListPeerItem +import ItemListPeerActionItem + +private final class ChatListFilterPresetControllerArguments { + let context: AccountContext + let updateState: ((ChatListFilterPresetControllerState) -> ChatListFilterPresetControllerState) -> Void + let openAddPeer: () -> Void + let deleteAdditionalPeer: (PeerId) -> Void + let setPeerIdWithRevealedOptions: (PeerId?, PeerId?) -> Void + + init(context: AccountContext, updateState: @escaping ((ChatListFilterPresetControllerState) -> ChatListFilterPresetControllerState) -> Void, openAddPeer: @escaping () -> Void, deleteAdditionalPeer: @escaping (PeerId) -> Void, setPeerIdWithRevealedOptions: @escaping (PeerId?, PeerId?) -> Void) { + self.context = context + self.updateState = updateState + self.openAddPeer = openAddPeer + self.deleteAdditionalPeer = deleteAdditionalPeer + self.setPeerIdWithRevealedOptions = setPeerIdWithRevealedOptions + } +} + +private enum ChatListFilterPresetControllerSection: Int32 { + case name + case categories + case excludeCategories + case additionalPeers +} + +private func filterEntry(presentationData: ItemListPresentationData, arguments: ChatListFilterPresetControllerArguments, title: String, value: Bool, filter: ChatListIncludeCategoryFilter, section: Int32) -> ItemListCheckboxItem { + return ItemListCheckboxItem(presentationData: presentationData, title: title, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: section, action: { + arguments.updateState { current in + var state = current + if state.includeCategories.contains(filter) { + state.includeCategories.remove(filter) + } else { + state.includeCategories.insert(filter) + } + return state + } + }) +} + +private enum ChatListFilterPresetEntryStableId: Hashable { + case index(Int) + case peer(PeerId) +} + +private enum ChatListFilterPresetEntry: ItemListNodeEntry { + case name(placeholder: String, value: String) + case filterPrivateChats(title: String, value: Bool) + case filterSecretChats(title: String, value: Bool) + case filterPrivateGroups(title: String, value: Bool) + case filterBots(title: String, value: Bool) + case filterPublicGroups(title: String, value: Bool) + case filterChannels(title: String, value: Bool) + case filterMuted(title: String, value: Bool) + case filterRead(title: String, value: Bool) + case additionalPeersHeader(String) + case addAdditionalPeer(title: String) + case additionalPeer(index: Int, peer: RenderedPeer, isRevealed: Bool) + + var section: ItemListSectionId { + switch self { + case .name: + return ChatListFilterPresetControllerSection.name.rawValue + case .filterPrivateChats, .filterSecretChats, .filterPrivateGroups, .filterBots, .filterPublicGroups, .filterChannels: + return ChatListFilterPresetControllerSection.categories.rawValue + case .filterMuted, .filterRead: + return ChatListFilterPresetControllerSection.excludeCategories.rawValue + case .additionalPeersHeader, .addAdditionalPeer, .additionalPeer: + return ChatListFilterPresetControllerSection.additionalPeers.rawValue + } + } + + var stableId: ChatListFilterPresetEntryStableId { + switch self { + case .name: + return .index(0) + case .filterPrivateChats: + return .index(1) + case .filterSecretChats: + return .index(2) + case .filterPrivateGroups: + return .index(3) + case .filterBots: + return .index(4) + case .filterPublicGroups: + return .index(5) + case .filterChannels: + return .index(6) + case .filterMuted: + return .index(7) + case .filterRead: + return .index(8) + case .additionalPeersHeader: + return .index(9) + case .addAdditionalPeer: + return .index(10) + case let .additionalPeer(additionalPeer): + return .peer(additionalPeer.peer.peerId) + } + } + + static func <(lhs: ChatListFilterPresetEntry, rhs: ChatListFilterPresetEntry) -> Bool { + switch lhs.stableId { + case let .index(lhsIndex): + switch rhs.stableId { + case let .index(rhsIndex): + return lhsIndex < rhsIndex + case .peer: + return true + } + case .peer: + switch lhs { + case let .additionalPeer(lhsIndex, _, _): + switch rhs.stableId { + case .index: + return false + case .peer: + switch rhs { + case let .additionalPeer(rhsIndex, _, _): + return lhsIndex < rhsIndex + default: + preconditionFailure() + } + } + default: + preconditionFailure() + } + } + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! ChatListFilterPresetControllerArguments + switch self { + case let .name(placeholder, value): + return ItemListSingleLineInputItem(presentationData: presentationData, title: NSAttributedString(), text: value, placeholder: placeholder, type: .regular(capitalization: true, autocorrection: false), sectionId: self.section, textUpdated: { value in + arguments.updateState { current in + var state = current + state.name = value + return state + } + }, action: {}) + case let .filterPrivateChats(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .privateChats, section: self.section) + case let .filterSecretChats(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .secretChats, section: self.section) + case let .filterPrivateGroups(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .privateGroups, section: self.section) + case let .filterBots(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .bots, section: self.section) + case let .filterPublicGroups(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .publicGroups, section: self.section) + case let .filterChannels(title, value): + return filterEntry(presentationData: presentationData, arguments: arguments, title: title, value: value, filter: .channels, section: self.section) + case let .filterMuted(title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { _ in + arguments.updateState { current in + var state = current + if state.includeCategories.contains(.muted) { + state.includeCategories.remove(.muted) + } else { + state.includeCategories.insert(.muted) + } + return state + } + }) + case let .filterRead(title, value): + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { _ in + arguments.updateState { current in + var state = current + if state.includeCategories.contains(.read) { + state.includeCategories.remove(.read) + } else { + state.includeCategories.insert(.read) + } + return state + } + }) + case let .additionalPeersHeader(title): + return ItemListSectionHeaderItem(presentationData: presentationData, text: title, sectionId: self.section) + case let .addAdditionalPeer(title): + return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.addPersonIcon(presentationData.theme), title: title, alwaysPlain: false, sectionId: self.section, height: .peerList, editing: false, action: { + arguments.openAddPeer() + }) + case let .additionalPeer(title, peer, isRevealed): + return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: PresentationDateTimeFormat(timeFormat: .regular, dateFormat: .monthFirst, dateSeparator: ".", decimalSeparator: ".", groupingSeparator: "."), nameDisplayOrder: .firstLast, context: arguments.context, peer: peer.chatMainPeer!, height: .peerList, presence: nil, text: .none, label: .none, editing: ItemListPeerItemEditing(editable: true, editing: false, revealed: isRevealed), revealOptions: ItemListPeerItemRevealOptions(options: [ItemListPeerItemRevealOption(type: .destructive, title: presentationData.strings.Common_Delete, action: { + arguments.deleteAdditionalPeer(peer.peerId) + })]), switchValue: nil, enabled: true, selectable: false, sectionId: self.section, action: nil, setPeerIdWithRevealedOptions: { lhs, rhs in + arguments.setPeerIdWithRevealedOptions(lhs, rhs) + }, removePeer: { id in + arguments.deleteAdditionalPeer(id) + }) + } + } +} + +private struct ChatListFilterPresetControllerState: Equatable { + var name: String + var includeCategories: ChatListIncludeCategoryFilter + var additionallyIncludePeers: [PeerId] + + var revealedPeerId: PeerId? + + var isComplete: Bool { + if self.name.isEmpty { + return false + } + if self.includeCategories.isEmpty && self.additionallyIncludePeers.isEmpty { + return false + } + return true + } +} + +private func chatListFilterPresetControllerEntries(presentationData: PresentationData, state: ChatListFilterPresetControllerState, peers: [RenderedPeer]) -> [ChatListFilterPresetEntry] { + var entries: [ChatListFilterPresetEntry] = [] + + entries.append(.name(placeholder: "Preset Name", value: state.name)) + + entries.append(.filterPrivateChats(title: "Private Chats", value: state.includeCategories.contains(.privateChats))) + entries.append(.filterSecretChats(title: "Secret Chats", value: state.includeCategories.contains(.secretChats))) + entries.append(.filterPrivateGroups(title: "Private Groups", value: state.includeCategories.contains(.privateGroups))) + entries.append(.filterBots(title: "Bots", value: state.includeCategories.contains(.bots))) + entries.append(.filterPublicGroups(title: "Public Groups", value: state.includeCategories.contains(.publicGroups))) + entries.append(.filterChannels(title: "Channels", value: state.includeCategories.contains(.channels))) + + entries.append(.filterMuted(title: "Exclude Muted", value: !state.includeCategories.contains(.muted))) + entries.append(.filterRead(title: "Exclude Read", value: !state.includeCategories.contains(.read))) + + entries.append(.additionalPeersHeader("ALWAYS INCLUDE")) + entries.append(.addAdditionalPeer(title: "Add")) + + for peer in peers { + entries.append(.additionalPeer(index: entries.count, peer: peer, isRevealed: state.revealedPeerId == peer.peerId)) + } + + return entries +} + +func chatListFilterPresetController(context: AccountContext, currentPreset: ChatListFilterPreset?) -> ViewController { + let initialName: String + if let currentPreset = currentPreset { + switch currentPreset.name { + case .unread: + initialName = "Unread" + case let .custom(value): + initialName = value + } + } else { + initialName = "New Preset" + } + let initialState = ChatListFilterPresetControllerState(name: initialName, includeCategories: currentPreset?.includeCategories ?? .all, additionallyIncludePeers: currentPreset?.additionallyIncludePeers ?? []) + let stateValue = Atomic(value: initialState) + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let updateState: ((ChatListFilterPresetControllerState) -> ChatListFilterPresetControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + let actionsDisposable = DisposableSet() + + let addPeerDisposable = MetaDisposable() + actionsDisposable.add(addPeerDisposable) + + var presentControllerImpl: ((ViewController, Any?) -> Void)? + var dismissImpl: (() -> Void)? + + let arguments = ChatListFilterPresetControllerArguments( + context: context, + updateState: { f in + updateState(f) + }, + openAddPeer: { + let controller = context.sharedContext.makeContactMultiselectionController(ContactMultiselectionControllerParams(context: context, mode: .peerSelection(searchChatList: true, searchGroups: true), options: [])) + addPeerDisposable.set((controller.result + |> take(1) + |> deliverOnMainQueue).start(next: { [weak controller] peerIds in + controller?.dismiss() + updateState { state in + var state = state + for peerId in peerIds { + switch peerId { + case let .peer(id): + if !state.additionallyIncludePeers.contains(id) { + state.additionallyIncludePeers.append(id) + } + default: + break + } + } + return state + } + })) + presentControllerImpl?(controller, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + }, + deleteAdditionalPeer: { peerId in + updateState { state in + var state = state + if let index = state.additionallyIncludePeers.index(of: peerId) { + state.additionallyIncludePeers.remove(at: index) + } + return state + } + }, + setPeerIdWithRevealedOptions: { peerId, fromPeerId in + updateState { state in + var state = state + if (peerId == nil && fromPeerId == state.revealedPeerId) || (peerId != nil && fromPeerId == nil) { + state.revealedPeerId = peerId + } + return state + } + } + ) + + let statePeers = statePromise.get() + |> map { state -> [PeerId] in + return state.additionallyIncludePeers + } + |> distinctUntilChanged + |> mapToSignal { peerIds -> Signal<[RenderedPeer], NoError> in + return context.account.postbox.transaction { transaction -> [RenderedPeer] in + var result: [RenderedPeer] = [] + for peerId in peerIds { + if let peer = transaction.getPeer(peerId) { + result.append(RenderedPeer(peer: peer)) + } + } + return result + } + } + + let signal = combineLatest(queue: .mainQueue(), + context.sharedContext.presentationData, + statePromise.get(), + statePeers + ) + |> deliverOnMainQueue + |> map { presentationData, state, statePeers -> (ItemListControllerState, (ItemListNodeState, Any)) in + let leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Cancel), style: .regular, enabled: true, action: { + dismissImpl?() + }) + let rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Done), style: .bold, enabled: state.isComplete, action: { + let state = stateValue.with { $0 } + let preset = ChatListFilterPreset(name: .custom(state.name), includeCategories: state.includeCategories, additionallyIncludePeers: state.additionallyIncludePeers) + let _ = (updateChatListFilterSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + settings.presets = settings.presets.filter { $0 != preset && $0 != currentPreset } + settings.presets.append(preset) + return settings + }) + |> deliverOnMainQueue).start(completed: { + dismissImpl?() + }) + }) + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(presentationData.strings.SocksProxySetup_Title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: chatListFilterPresetControllerEntries(presentationData: presentationData, state: state, peers: statePeers), style: .blocks, emptyStateItem: nil, animateChanges: true) + + return (controllerState, (listState, arguments)) + } + |> afterDisposed { + actionsDisposable.dispose() + } + + let controller = ItemListController(context: context, state: signal) + controller.navigationPresentation = .modal + presentControllerImpl = { [weak controller] c, d in + controller?.present(c, in: .window(.root), with: d) + } + dismissImpl = { [weak controller] in + let _ = controller?.dismiss() + } + + return controller +} + diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift new file mode 100644 index 0000000000..d1f55c8d6d --- /dev/null +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetListController.swift @@ -0,0 +1,212 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import SyncCore +import TelegramPresentationData +import TelegramUIPreferences +import ItemListUI +import PresentationDataUtils +import AccountContext + +private final class ChatListFilterPresetListControllerArguments { + let context: AccountContext + + let openPreset: (ChatListFilterPreset) -> Void + let addNew: () -> Void + let setItemWithRevealedOptions: (ChatListFilterPreset?, ChatListFilterPreset?) -> Void + let removePreset: (ChatListFilterPreset) -> Void + + init(context: AccountContext, openPreset: @escaping (ChatListFilterPreset) -> Void, addNew: @escaping () -> Void, setItemWithRevealedOptions: @escaping (ChatListFilterPreset?, ChatListFilterPreset?) -> Void, removePreset: @escaping (ChatListFilterPreset) -> Void) { + self.context = context + self.openPreset = openPreset + self.addNew = addNew + self.setItemWithRevealedOptions = setItemWithRevealedOptions + self.removePreset = removePreset + } +} + +private enum ChatListFilterPresetListSection: Int32 { + case list +} + +private func stringForUserCount(_ peers: [PeerId: SelectivePrivacyPeer], strings: PresentationStrings) -> String { + if peers.isEmpty { + return strings.PrivacyLastSeenSettings_EmpryUsersPlaceholder + } else { + var result = 0 + for (_, peer) in peers { + result += peer.userCount + } + return strings.UserCount(Int32(result)) + } +} + +private enum ChatListFilterPresetListEntryStableId: Hashable { + case listHeader + case preset(ChatListFilterPresetName) + case addItem + case listFooter +} + +private enum ChatListFilterPresetListEntry: ItemListNodeEntry { + case listHeader(String) + case preset(index: Int, title: String, preset: ChatListFilterPreset, canBeReordered: Bool, canBeDeleted: Bool) + case addItem(String) + case listFooter(String) + + var section: ItemListSectionId { + switch self { + case .listHeader, .preset, .addItem, .listFooter: + return ChatListFilterPresetListSection.list.rawValue + } + } + + var sortId: Int { + switch self { + case .listHeader: + return 0 + case let .preset(preset): + return 1 + preset.index + case .addItem: + return 1000 + case .listFooter: + return 1001 + } + } + + var stableId: ChatListFilterPresetListEntryStableId { + switch self { + case .listHeader: + return .listHeader + case let .preset(preset): + return .preset(preset.preset.name) + case .addItem: + return .addItem + case .listFooter: + return .listFooter + } + } + + static func <(lhs: ChatListFilterPresetListEntry, rhs: ChatListFilterPresetListEntry) -> Bool { + return lhs.sortId < rhs.sortId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! ChatListFilterPresetListControllerArguments + switch self { + case let .listHeader(text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, multiline: true, sectionId: self.section) + case let .preset(index, title, preset, canBeReordered, canBeDeleted): + return ChatListFilterPresetListItem(presentationData: presentationData, preset: preset, title: title, editing: ChatListFilterPresetListItemEditing(editable: true, editing: false, revealed: false), canBeReordered: canBeReordered, canBeDeleted: canBeDeleted, sectionId: self.section, action: { + arguments.openPreset(preset) + }, setItemWithRevealedOptions: { lhs, rhs in + arguments.setItemWithRevealedOptions(lhs, rhs) + }, remove: { + arguments.removePreset(preset) + }) + case let .addItem(text): + return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { + arguments.addNew() + }) + case let .listFooter(text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + } + } +} + +private struct ChatListFilterPresetListControllerState: Equatable { + var revealedPreset: ChatListFilterPreset? = nil +} + +private func chatListFilterPresetListControllerEntries(presentationData: PresentationData, state: ChatListFilterPresetListControllerState, settings: ChatListFilterSettings) -> [ChatListFilterPresetListEntry] { + var entries: [ChatListFilterPresetListEntry] = [] + + entries.append(.listHeader("PRESETS")) + for preset in settings.presets { + let title: String + switch preset.name { + case .unread: + title = "Unread" + case let .custom(value): + title = value + } + entries.append(.preset(index: entries.count, title: title, preset: preset, canBeReordered: settings.presets.count > 1, canBeDeleted: true)) + } + entries.append(.addItem("Add New")) + entries.append(.listFooter("Add custom presets")) + + return entries +} + +func chatListFilterPresetListController(context: AccountContext) -> ViewController { + let initialState = ChatListFilterPresetListControllerState() + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((ChatListFilterPresetListControllerState) -> ChatListFilterPresetListControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + var dismissImpl: (() -> Void)? + var pushControllerImpl: ((ViewController) -> Void)? + var presentControllerImpl: ((ViewController, Any?) -> Void)? + + let arguments = ChatListFilterPresetListControllerArguments(context: context, openPreset: { preset in + pushControllerImpl?(chatListFilterPresetController(context: context, currentPreset: preset)) + }, addNew: { + pushControllerImpl?(chatListFilterPresetController(context: context, currentPreset: nil)) + }, setItemWithRevealedOptions: { preset, fromPreset in + updateState { state in + var state = state + if (preset == nil && fromPreset == state.revealedPreset) || (preset != nil && fromPreset == nil) { + state.revealedPreset = preset + } + return state + } + }, removePreset: { preset in + let _ = updateChatListFilterSettingsInteractively(postbox: context.account.postbox, { settings in + var settings = settings + if let index = settings.presets.index(of: preset) { + settings.presets.remove(at: index) + } + return settings + }).start() + }) + + let preferences = context.account.postbox.preferencesView(keys: [ApplicationSpecificPreferencesKeys.chatListFilterSettings]) + + let signal = combineLatest(queue: .mainQueue(), + context.sharedContext.presentationData, + statePromise.get(), + preferences + ) + |> map { presentationData, state, preferences -> (ItemListControllerState, (ItemListNodeState, Any)) in + let settings = preferences.values[ApplicationSpecificPreferencesKeys.chatListFilterSettings] as? ChatListFilterSettings ?? ChatListFilterSettings.default + let leftNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Close), style: .regular, enabled: true, action: { + dismissImpl?() + }) + + let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Filter Presets"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: false) + let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: chatListFilterPresetListControllerEntries(presentationData: presentationData, state: state, settings: settings), style: .blocks, animateChanges: true) + + return (controllerState, (listState, arguments)) + } + |> afterDisposed { + } + + let controller = ItemListController(context: context, state: signal) + controller.navigationPresentation = .modal + pushControllerImpl = { [weak controller] c in + controller?.push(c) + } + presentControllerImpl = { [weak controller] c, a in + controller?.present(c, in: .window(.root), with: a) + } + dismissImpl = { [weak controller] in + controller?.dismiss() + } + + return controller +} diff --git a/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift b/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift new file mode 100644 index 0000000000..661dc45468 --- /dev/null +++ b/submodules/ChatListUI/Sources/ChatListFilterPresetListItem.swift @@ -0,0 +1,439 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore +import SyncCore +import TelegramPresentationData +import ItemListUI +import PresentationDataUtils +import ActivityIndicator +import TelegramUIPreferences + +struct ChatListFilterPresetListItemEditing: Equatable { + let editable: Bool + let editing: Bool + let revealed: Bool +} + +final class ChatListFilterPresetListItem: ListViewItem, ItemListItem { + let presentationData: ItemListPresentationData + let preset: ChatListFilterPreset + let title: String + let editing: ChatListFilterPresetListItemEditing + let canBeReordered: Bool + let canBeDeleted: Bool + let sectionId: ItemListSectionId + let action: () -> Void + let setItemWithRevealedOptions: (ChatListFilterPreset?, ChatListFilterPreset?) -> Void + let remove: () -> Void + + init( + presentationData: ItemListPresentationData, + preset: ChatListFilterPreset, + title: String, + editing: ChatListFilterPresetListItemEditing, + canBeReordered: Bool, + canBeDeleted: Bool, + sectionId: ItemListSectionId, + action: @escaping () -> Void, + setItemWithRevealedOptions: @escaping (ChatListFilterPreset?, ChatListFilterPreset?) -> Void, + remove: @escaping () -> Void + ) { + self.presentationData = presentationData + self.preset = preset + self.title = title + self.editing = editing + self.canBeReordered = canBeReordered + self.canBeDeleted = canBeDeleted + self.sectionId = sectionId + self.action = action + self.setItemWithRevealedOptions = setItemWithRevealedOptions + self.remove = remove + } + + func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal?, (ListViewItemApply) -> Void)) -> Void) { + async { + let node = ChatListFilterPresetListItemNode() + let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + Queue.mainQueue().async { + completion(node, { + return (nil, { _ in apply(false) }) + }) + } + } + } + + func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) { + Queue.mainQueue().async { + if let nodeValue = node() as? ChatListFilterPresetListItemNode { + let makeLayout = nodeValue.asyncLayout() + + var animated = true + if case .None = animation { + animated = false + } + + async { + let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem)) + Queue.mainQueue().async { + completion(layout, { _ in + apply(animated) + }) + } + } + } + } + } + + var selectable: Bool = true + + func selected(listView: ListView){ + listView.clearHighlightAnimated(true) + self.action() + } +} + +private let titleFont = Font.regular(17.0) + +private final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode { + private let backgroundNode: ASDisplayNode + private let topStripeNode: ASDisplayNode + private let bottomStripeNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private let maskNode: ASImageNode + + private let titleNode: TextNode + + private let activateArea: AccessibilityAreaNode + + private var editableControlNode: ItemListEditableControlNode? + private var reorderControlNode: ItemListEditableReorderControlNode? + + private var item: ChatListFilterPresetListItem? + private var layoutParams: ListViewItemLayoutParams? + + override var canBeSelected: Bool { + if self.editableControlNode != nil { + return false + } + return true + } + + init() { + self.backgroundNode = ASDisplayNode() + self.backgroundNode.isLayerBacked = true + + self.topStripeNode = ASDisplayNode() + self.topStripeNode.isLayerBacked = true + + self.bottomStripeNode = ASDisplayNode() + self.bottomStripeNode.isLayerBacked = true + + self.maskNode = ASImageNode() + + self.titleNode = TextNode() + self.titleNode.isUserInteractionEnabled = false + self.titleNode.contentMode = .left + self.titleNode.contentsScale = UIScreen.main.scale + + self.activateArea = AccessibilityAreaNode() + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.isLayerBacked = true + + super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false) + + self.addSubnode(self.titleNode) + self.addSubnode(self.activateArea) + + self.activateArea.activate = { [weak self] in + self?.item?.action() + return true + } + } + + func asyncLayout() -> (_ item: ChatListFilterPresetListItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode) + let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode) + + let currentItem = self.item + + return { item, params, neighbors in + var updatedTheme: PresentationTheme? + + if currentItem?.presentationData.theme !== item.presentationData.theme { + updatedTheme = item.presentationData.theme + } + + let peerRevealOptions: [ItemListRevealOption] + if item.editing.editable && item.canBeDeleted { + peerRevealOptions = [ItemListRevealOption(key: 0, title: item.presentationData.strings.Common_Delete, icon: .none, color: item.presentationData.theme.list.itemDisclosureActions.destructive.fillColor, textColor: item.presentationData.theme.list.itemDisclosureActions.destructive.foregroundColor)] + } else { + peerRevealOptions = [] + } + + let titleAttributedString = NSMutableAttributedString() + titleAttributedString.append(NSAttributedString(string: item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor)) + + var editableControlSizeAndApply: (CGFloat, (CGFloat) -> ItemListEditableControlNode)? + var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)? + + let editingOffset: CGFloat = 0.0 + var reorderInset: CGFloat = 0.0 + + if item.editing.editing && item.canBeReordered { + /*let sizeAndApply = editableControlLayout(item.presentationData.theme, false) + editableControlSizeAndApply = sizeAndApply + editingOffset = sizeAndApply.0*/ + + let reorderSizeAndApply = reorderControlLayout(item.presentationData.theme) + reorderControlSizeAndApply = reorderSizeAndApply + reorderInset = reorderSizeAndApply.0 + } + + let leftInset: CGFloat = 16.0 + params.leftInset + let rightInset: CGFloat = params.rightInset + max(reorderInset, 55.0) + + let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: titleAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: params.width - leftInset - 12.0 - editingOffset - rightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) + + let insets = itemListNeighborsGroupedInsets(neighbors) + let contentSize = CGSize(width: params.width, height: titleLayout.size.height + 11.0 * 2.0) + let separatorHeight = UIScreenPixel + + let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets) + let layoutSize = layout.size + + return (layout, { [weak self] animated in + if let strongSelf = self { + strongSelf.item = item + strongSelf.layoutParams = params + + strongSelf.activateArea.accessibilityLabel = "\(titleAttributedString.string))" + + if let _ = updatedTheme { + strongSelf.topStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor + strongSelf.bottomStripeNode.backgroundColor = item.presentationData.theme.list.itemBlocksSeparatorColor + strongSelf.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor + strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.list.itemHighlightedBackgroundColor + } + + let revealOffset = strongSelf.revealOffset + + let transition: ContainedViewLayoutTransition + if animated { + transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + } else { + transition = .immediate + } + + if let editableControlSizeAndApply = editableControlSizeAndApply { + let editableControlFrame = CGRect(origin: CGPoint(x: params.leftInset + revealOffset, y: 0.0), size: CGSize(width: editableControlSizeAndApply.0, height: layout.contentSize.height)) + if strongSelf.editableControlNode == nil { + let editableControlNode = editableControlSizeAndApply.1(layout.contentSize.height) + editableControlNode.tapped = { + if let strongSelf = self { + strongSelf.setRevealOptionsOpened(true, animated: true) + strongSelf.revealOptionsInteractivelyOpened() + } + } + strongSelf.editableControlNode = editableControlNode + strongSelf.insertSubnode(editableControlNode, aboveSubnode: strongSelf.titleNode) + editableControlNode.frame = editableControlFrame + transition.animatePosition(node: editableControlNode, from: CGPoint(x: -editableControlFrame.size.width / 2.0, y: editableControlFrame.midY)) + editableControlNode.alpha = 0.0 + transition.updateAlpha(node: editableControlNode, alpha: 1.0) + } else { + strongSelf.editableControlNode?.frame = editableControlFrame + } + strongSelf.editableControlNode?.isHidden = !item.editing.editable + } else if let editableControlNode = strongSelf.editableControlNode { + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = -editableControlFrame.size.width + strongSelf.editableControlNode = nil + transition.updateAlpha(node: editableControlNode, alpha: 0.0) + transition.updateFrame(node: editableControlNode, frame: editableControlFrame, completion: { [weak editableControlNode] _ in + editableControlNode?.removeFromSupernode() + }) + } + + if let reorderControlSizeAndApply = reorderControlSizeAndApply { + if strongSelf.reorderControlNode == nil { + let reorderControlNode = reorderControlSizeAndApply.1(layout.contentSize.height, false, .immediate) + strongSelf.reorderControlNode = reorderControlNode + strongSelf.addSubnode(reorderControlNode) + reorderControlNode.alpha = 0.0 + transition.updateAlpha(node: reorderControlNode, alpha: 1.0) + } + let reorderControlFrame = CGRect(origin: CGPoint(x: params.width + revealOffset - params.rightInset - reorderControlSizeAndApply.0, y: 0.0), size: CGSize(width: reorderControlSizeAndApply.0, height: layout.contentSize.height)) + strongSelf.reorderControlNode?.frame = reorderControlFrame + } else if let reorderControlNode = strongSelf.reorderControlNode { + strongSelf.reorderControlNode = nil + transition.updateAlpha(node: reorderControlNode, alpha: 0.0, completion: { [weak reorderControlNode] _ in + reorderControlNode?.removeFromSupernode() + }) + } + + let _ = titleApply() + + if strongSelf.backgroundNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0) + } + if strongSelf.topStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1) + } + if strongSelf.bottomStripeNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2) + } + if strongSelf.maskNode.supernode == nil { + strongSelf.insertSubnode(strongSelf.maskNode, at: 3) + } + + let hasCorners = itemListHasRoundedBlockLayout(params) + var hasTopCorners = false + var hasBottomCorners = false + switch neighbors.top { + case .sameSection(false): + strongSelf.topStripeNode.isHidden = true + default: + hasTopCorners = true + strongSelf.topStripeNode.isHidden = hasCorners + } + let bottomStripeInset: CGFloat + let bottomStripeOffset: CGFloat + switch neighbors.bottom { + case .sameSection(false): + bottomStripeInset = leftInset + editingOffset + bottomStripeOffset = -separatorHeight + default: + bottomStripeInset = 0.0 + bottomStripeOffset = 0.0 + hasBottomCorners = true + strongSelf.bottomStripeNode.isHidden = hasCorners + } + + 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) + 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 + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))) + + transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 11.0), size: titleLayout.size)) + + strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 0.0), size: CGSize(width: params.width - params.rightInset - 56.0 - (leftInset + revealOffset + editingOffset), height: layout.contentSize.height)) + + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: params.width, height: layout.contentSize.height + UIScreenPixel + UIScreenPixel)) + + strongSelf.updateLayout(size: layout.contentSize, leftInset: params.leftInset, rightInset: params.rightInset) + + strongSelf.setRevealOptions((left: [], right: peerRevealOptions)) + strongSelf.setRevealOptionsOpened(item.editing.revealed, animated: animated) + } + }) + } + } + + override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { + super.setHighlighted(highlighted, at: point, animated: animated) + + if highlighted { + self.highlightedBackgroundNode.alpha = 1.0 + if self.highlightedBackgroundNode.supernode == nil { + var anchorNode: ASDisplayNode? + if self.bottomStripeNode.supernode != nil { + anchorNode = self.bottomStripeNode + } else if self.topStripeNode.supernode != nil { + anchorNode = self.topStripeNode + } else if self.backgroundNode.supernode != nil { + anchorNode = self.backgroundNode + } + if let anchorNode = anchorNode { + self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: anchorNode) + } else { + self.addSubnode(self.highlightedBackgroundNode) + } + } + } else { + if self.highlightedBackgroundNode.supernode != nil { + if animated { + self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in + if let strongSelf = self { + if completed { + strongSelf.highlightedBackgroundNode.removeFromSupernode() + } + } + }) + self.highlightedBackgroundNode.alpha = 0.0 + } else { + self.highlightedBackgroundNode.removeFromSupernode() + } + } + } + } + + override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { + self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4) + } + + override func animateRemoved(_ currentTimestamp: Double, duration: Double) { + self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) + } + + override func updateRevealOffset(offset: CGFloat, transition: ContainedViewLayoutTransition) { + super.updateRevealOffset(offset: offset, transition: transition) + + guard let params = self.layoutParams else { + return + } + + let leftInset: CGFloat = 16.0 + params.leftInset + + let editingOffset: CGFloat + if let editableControlNode = self.editableControlNode { + editingOffset = editableControlNode.bounds.size.width + var editableControlFrame = editableControlNode.frame + editableControlFrame.origin.x = params.leftInset + offset + transition.updateFrame(node: editableControlNode, frame: editableControlFrame) + } else { + editingOffset = 0.0 + } + + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + offset + editingOffset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size)) + } + + override func revealOptionsInteractivelyOpened() { + if let item = self.item { + item.setItemWithRevealedOptions(item.preset, nil) + } + } + + override func revealOptionsInteractivelyClosed() { + if let item = self.item { + item.setItemWithRevealedOptions(nil, item.preset) + } + } + + override func revealOptionSelected(_ option: ItemListRevealOption, animated: Bool) { + self.setRevealOptionsOpened(false, animated: true) + self.revealOptionsInteractivelyClosed() + + if let item = self.item { + item.remove() + } + } + + override func isReorderable(at point: CGPoint) -> Bool { + if let reorderControlNode = self.reorderControlNode, reorderControlNode.frame.contains(point), !self.isDisplayingRevealedOptions { + return true + } + return false + } +} diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 0d6f40954d..ab9abd8fe3 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -366,14 +366,17 @@ public final class ChatListNode: ListView { } private var currentLocation: ChatListNodeLocation? - var chatListFilter: ChatListNodeFilter = .all { + var chatListFilter: ChatListFilterPreset? { didSet { if self.chatListFilter != oldValue { self.chatListFilterValue.set(self.chatListFilter) } } } - private let chatListFilterValue = ValuePromise(.all) + private let chatListFilterValue = ValuePromise(nil) + var chatListFilterSignal: Signal { + return self.chatListFilterValue.get() + } private let chatListLocation = ValuePromise() private let chatListDisposable = MetaDisposable() private var activityStatusesDisposable: Disposable? @@ -540,7 +543,7 @@ public final class ChatListNode: ListView { } return true }) - |> mapToSignal { location, filter -> Signal<(ChatListNodeViewUpdate, ChatListNodeFilter), NoError> in + |> mapToSignal { location, filter -> Signal<(ChatListNodeViewUpdate, ChatListFilterPreset?), NoError> in return chatListViewForLocation(groupId: groupId, filter: filter, location: location, account: context.account) |> map { update in return (update, filter) diff --git a/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift b/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift index 93e631b8f6..231509cbe6 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNodeLocation.swift @@ -4,6 +4,7 @@ import TelegramCore import SyncCore import SwiftSignalKit import Display +import TelegramUIPreferences enum ChatListNodeLocation: Equatable { case initial(count: Int) @@ -31,35 +32,20 @@ struct ChatListNodeViewUpdate { let scrollPosition: ChatListNodeViewScrollPosition? } -struct ChatListNodeFilter: OptionSet { - var rawValue: Int32 - - init(rawValue: Int32) { - self.rawValue = rawValue - } - - static let muted = ChatListNodeFilter(rawValue: 1 << 1) - static let privateChats = ChatListNodeFilter(rawValue: 1 << 2) - static let groups = ChatListNodeFilter(rawValue: 1 << 3) - static let bots = ChatListNodeFilter(rawValue: 1 << 4) - static let channels = ChatListNodeFilter(rawValue: 1 << 5) - - static let all: ChatListNodeFilter = [ - .muted, - .privateChats, - .groups, - .bots, - .channels - ] -} - -func chatListViewForLocation(groupId: PeerGroupId, filter: ChatListNodeFilter, location: ChatListNodeLocation, account: Account) -> Signal { - let filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? - if filter == .all { - filterPredicate = nil - } else { - filterPredicate = { peer, notificationSettings in - if !filter.contains(.muted) { +func chatListViewForLocation(groupId: PeerGroupId, filter: ChatListFilterPreset?, location: ChatListNodeLocation, account: Account) -> Signal { + let filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? + if let filter = filter { + let includePeers = Set(filter.additionallyIncludePeers) + filterPredicate = { peer, notificationSettings, isUnread in + if includePeers.contains(peer.id) { + return true + } + if !filter.includeCategories.contains(.read) { + if !isUnread { + return false + } + } + if !filter.includeCategories.contains(.muted) { if let notificationSettings = notificationSettings as? TelegramPeerNotificationSettings { if case .muted = notificationSettings.muteState { return false @@ -68,32 +54,46 @@ func chatListViewForLocation(groupId: PeerGroupId, filter: ChatListNodeFilter, l return false } } - if !filter.contains(.privateChats) { + if !filter.includeCategories.contains(.privateChats) { if let user = peer as? TelegramUser { if user.botInfo == nil { return false } - } else if let _ = peer as? TelegramSecretChat { + } + } + if !filter.includeCategories.contains(.secretChats) { + if let _ = peer as? TelegramSecretChat { return false } } - if !filter.contains(.bots) { + if !filter.includeCategories.contains(.bots) { if let user = peer as? TelegramUser { if user.botInfo != nil { return false } } } - if !filter.contains(.groups) { + if !filter.includeCategories.contains(.privateGroups) { if let _ = peer as? TelegramGroup { return false } else if let channel = peer as? TelegramChannel { if case .group = channel.info { - return false + if channel.username == nil { + return false + } } } } - if !filter.contains(.channels) { + if !filter.includeCategories.contains(.publicGroups) { + if let channel = peer as? TelegramChannel { + if case .group = channel.info { + if channel.username != nil { + return false + } + } + } + } + if !filter.includeCategories.contains(.channels) { if let channel = peer as? TelegramChannel { if case .broadcast = channel.info { return false @@ -102,6 +102,8 @@ func chatListViewForLocation(groupId: PeerGroupId, filter: ChatListNodeFilter, l } return true } + } else { + filterPredicate = nil } switch location { diff --git a/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift b/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift index 17350a0f77..f0a07f75cc 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListViewTransition.swift @@ -6,12 +6,13 @@ import SwiftSignalKit import Display import MergeLists import SearchUI +import TelegramUIPreferences struct ChatListNodeView { let originalView: ChatListView let filteredEntries: [ChatListNodeEntry] let isLoading: Bool - let filter: ChatListNodeFilter + let filter: ChatListFilterPreset? } enum ChatListNodeViewTransitionReason { diff --git a/submodules/ChatListUI/Sources/TabBarChatListFilterController.swift b/submodules/ChatListUI/Sources/TabBarChatListFilterController.swift index 204ee79dea..12bb93be3a 100644 --- a/submodules/ChatListUI/Sources/TabBarChatListFilterController.swift +++ b/submodules/ChatListUI/Sources/TabBarChatListFilterController.swift @@ -5,32 +5,39 @@ import SwiftSignalKit import AsyncDisplayKit import TelegramPresentationData import AccountContext +import SyncCore +import Postbox +import TelegramUIPreferences final class TabBarChatListFilterController: ViewController { private var controllerNode: TabBarChatListFilterControllerNode { return self.displayNode as! TabBarChatListFilterControllerNode } - private let _ready = Promise(true) + private let _ready = Promise() override public var ready: Promise { return self._ready } private let context: AccountContext private let sourceNodes: [ASDisplayNode] - private let currentFilter: ChatListNodeFilter - private let updateFilter: (ChatListNodeFilter) -> Void + private let presetList: [ChatListFilterPreset] + private let currentPreset: ChatListFilterPreset? + private let setup: () -> Void + private let updatePreset: (ChatListFilterPreset?) -> Void private var presentationData: PresentationData private var didPlayPresentationAnimation = false private let hapticFeedback = HapticFeedback() - public init(context: AccountContext, sourceNodes: [ASDisplayNode], currentFilter: ChatListNodeFilter, updateFilter: @escaping (ChatListNodeFilter) -> Void) { + public init(context: AccountContext, sourceNodes: [ASDisplayNode], presetList: [ChatListFilterPreset], currentPreset: ChatListFilterPreset?, setup: @escaping () -> Void, updatePreset: @escaping (ChatListFilterPreset?) -> Void) { self.context = context self.sourceNodes = sourceNodes - self.currentFilter = currentFilter - self.updateFilter = updateFilter + self.presetList = presetList + self.currentPreset = currentPreset + self.setup = setup + self.updatePreset = updatePreset self.presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -52,7 +59,14 @@ final class TabBarChatListFilterController: ViewController { override public func loadDisplayNode() { self.displayNode = TabBarChatListFilterControllerNode(context: self.context, presentationData: self.presentationData, cancel: { [weak self] in self?.dismiss() - }, sourceNodes: self.sourceNodes, currentFilter: self.currentFilter, updateFilter: self.updateFilter) + }, sourceNodes: self.sourceNodes, presetList: self.presetList, currentPreset: self.currentPreset, setup: { [weak self] in + self?.setup() + self?.dismiss(sourceNodes: [], fadeOutIcon: true) + }, updatePreset: { [weak self] filter in + self?.updatePreset(filter) + self?.dismiss() + }) + self._ready.set(self.controllerNode.isReady.get()) self.displayNodeDidLoad() } @@ -74,11 +88,11 @@ final class TabBarChatListFilterController: ViewController { } override public func dismiss(completion: (() -> Void)? = nil) { - self.dismiss(sourceNodes: []) + self.dismiss(sourceNodes: [], fadeOutIcon: false) } - public func dismiss(sourceNodes: [ASDisplayNode]) { - self.controllerNode.animateOut(sourceNodes: sourceNodes, completion: { [weak self] in + func dismiss(sourceNodes: [ASDisplayNode], fadeOutIcon: Bool) { + self.controllerNode.animateOut(sourceNodes: sourceNodes, fadeOutIcon: fadeOutIcon, completion: { [weak self] in self?.didPlayPresentationAnimation = false self?.presentingViewController?.dismiss(animated: false, completion: nil) }) @@ -91,9 +105,86 @@ private protocol AbstractTabBarChatListFilterItemNode { func updateLayout(maxWidth: CGFloat) -> (CGFloat, CGFloat, (CGFloat) -> Void) } +private final class AddFilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterItemNode { + private let action: () -> Void + + private let separatorNode: ASDisplayNode + private let highlightedBackgroundNode: ASDisplayNode + private let buttonNode: HighlightTrackingButtonNode + private let plusNode: ASImageNode + private let titleNode: ImmediateTextNode + + init(displaySeparator: Bool, presentationData: PresentationData, action: @escaping () -> Void) { + self.action = action + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = presentationData.theme.actionSheet.opaqueItemSeparatorColor + self.separatorNode.isHidden = !displaySeparator + + self.highlightedBackgroundNode = ASDisplayNode() + self.highlightedBackgroundNode.backgroundColor = presentationData.theme.actionSheet.opaqueItemHighlightedBackgroundColor + self.highlightedBackgroundNode.alpha = 0.0 + + self.buttonNode = HighlightTrackingButtonNode() + + self.titleNode = ImmediateTextNode() + self.titleNode.maximumNumberOfLines = 1 + self.titleNode.attributedText = NSAttributedString(string: "Setup", font: Font.regular(17.0), textColor: presentationData.theme.actionSheet.primaryTextColor) + + self.plusNode = ASImageNode() + self.plusNode.image = generateItemListPlusIcon(presentationData.theme.actionSheet.primaryTextColor) + + super.init() + + self.addSubnode(self.separatorNode) + self.addSubnode(self.highlightedBackgroundNode) + self.addSubnode(self.titleNode) + self.addSubnode(self.plusNode) + self.addSubnode(self.buttonNode) + + self.buttonNode.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: .touchUpInside) + self.buttonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.highlightedBackgroundNode.layer.removeAnimation(forKey: "opacity") + strongSelf.highlightedBackgroundNode.alpha = 1.0 + } else { + strongSelf.highlightedBackgroundNode.alpha = 0.0 + strongSelf.highlightedBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + } + } + } + } + + func updateLayout(maxWidth: CGFloat) -> (CGFloat, CGFloat, (CGFloat) -> Void) { + let leftInset: CGFloat = 16.0 + let rightInset: CGFloat = 10.0 + let iconInset: CGFloat = 60.0 + let titleSize = self.titleNode.updateLayout(CGSize(width: maxWidth - leftInset - rightInset, height: .greatestFiniteMagnitude)) + let height: CGFloat = 61.0 + + return (titleSize.width + leftInset + rightInset, height, { width in + self.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize) + + if let image = self.plusNode.image { + self.plusNode.frame = CGRect(origin: CGPoint(x: floor(width - iconInset + (iconInset - image.size.width) / 2.0), y: floor((height - image.size.height) / 2.0)), size: image.size) + } + + self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: height - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel)) + self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: height)) + self.buttonNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: height)) + }) + } + + @objc private func buttonPressed() { + self.action() + } +} + private final class FilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterItemNode { private let context: AccountContext private let title: String + let preset: ChatListFilterPreset? private let isCurrent: Bool private let presentationData: PresentationData private let action: () -> Bool @@ -106,10 +197,12 @@ private final class FilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterI private let badgeBackgroundNode: ASImageNode private let badgeTitleNode: ImmediateTextNode + private var badgeText: String = "" - init(context: AccountContext, title: String, isCurrent: Bool, displaySeparator: Bool, presentationData: PresentationData, action: @escaping () -> Bool) { + init(context: AccountContext, title: String, preset: ChatListFilterPreset?, isCurrent: Bool, displaySeparator: Bool, presentationData: PresentationData, action: @escaping () -> Bool) { self.context = context self.title = title + self.preset = preset self.isCurrent = isCurrent self.presentationData = presentationData self.action = action @@ -130,7 +223,7 @@ private final class FilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterI self.checkNode = ASImageNode() self.checkNode.image = generateItemListCheckIcon(color: presentationData.theme.actionSheet.primaryTextColor) - self.checkNode.isHidden = !isCurrent + self.checkNode.isHidden = true//!isCurrent self.badgeBackgroundNode = ASImageNode() self.badgeBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 20.0, color: presentationData.theme.list.itemCheckColors.fillColor) @@ -169,7 +262,7 @@ private final class FilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterI let badgeMinSize = self.badgeBackgroundNode.image?.size.width ?? 20.0 let badgeSize = CGSize(width: max(badgeMinSize, badgeTitleSize.width + 12.0), height: badgeMinSize) - let rightInset: CGFloat = max(60.0, badgeSize.width + 40.0) + let rightInset: CGFloat = max(20.0, badgeSize.width + 20.0) let titleSize = self.titleNode.updateLayout(CGSize(width: maxWidth - leftInset - rightInset, height: .greatestFiniteMagnitude)) @@ -193,8 +286,20 @@ private final class FilterItemNode: ASDisplayNode, AbstractTabBarChatListFilterI } @objc private func buttonPressed() { - let isCurrent = self.action() - self.checkNode.isHidden = !isCurrent + let _ = self.action() + //self.checkNode.isHidden = !isCurrent + } + + func updateBadge(text: String) -> Bool { + if text != self.badgeText { + self.badgeText = text + self.badgeTitleNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: self.presentationData.theme.list.itemCheckColors.foregroundColor) + self.badgeBackgroundNode.isHidden = text.isEmpty + self.badgeTitleNode.isHidden = text.isEmpty + return true + } else { + return false + } } } @@ -215,7 +320,11 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod private var validLayout: ContainerViewLayout? - init(context: AccountContext, presentationData: PresentationData, cancel: @escaping () -> Void, sourceNodes: [ASDisplayNode], currentFilter: ChatListNodeFilter, updateFilter: @escaping (ChatListNodeFilter) -> Void) { + private var countsDisposable: Disposable? + let isReady = Promise() + private var didSetIsReady = false + + init(context: AccountContext, presentationData: PresentationData, cancel: @escaping () -> Void, sourceNodes: [ASDisplayNode], presetList: [ChatListFilterPreset], currentPreset: ChatListFilterPreset?, setup: @escaping () -> Void, updatePreset: @escaping (ChatListFilterPreset?) -> Void) { self.presentationData = presentationData self.cancel = cancel self.sourceNodes = sourceNodes @@ -245,30 +354,28 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod self.contentContainerNode.clipsToBounds = true var contentNodes: [ASDisplayNode & AbstractTabBarChatListFilterItemNode] = [] + contentNodes.append(AddFilterItemNode(displaySeparator: true, presentationData: presentationData, action: { + setup() + })) - let labels: [(String, ChatListNodeFilter)] = [ - ("Private Chats", .privateChats), - ("Groups", .groups), - ("Bots", .bots), - ("Channels", .channels), - ("Muted", .muted) - ] + contentNodes.append(FilterItemNode(context: context, title: "All", preset: nil, isCurrent: currentPreset == nil, displaySeparator: !presetList.isEmpty, presentationData: presentationData, action: { + updatePreset(nil) + return false + })) - var updatedFilter = currentFilter - let toggleFilter: (ChatListNodeFilter) -> Void = { filter in - if updatedFilter.contains(filter) { - updatedFilter.remove(filter) - } else { - updatedFilter.insert(filter) + for i in 0 ..< presetList.count { + let preset = presetList[i] + + let title: String + switch preset.name { + case .unread: + title = "Unread" + case let .custom(value): + title = value } - updateFilter(updatedFilter) - } - - for i in 0 ..< labels.count { - let filter = labels[i].1 - contentNodes.append(FilterItemNode(context: context, title: labels[i].0, isCurrent: updatedFilter.contains(filter), displaySeparator: i != labels.count - 1, presentationData: presentationData, action: { - toggleFilter(filter) - return updatedFilter.contains(filter) + contentNodes.append(FilterItemNode(context: context, title: title, preset: preset, isCurrent: currentPreset == preset, displaySeparator: i != presetList.count - 1, presentationData: presentationData, action: { + updatePreset(preset) + return false })) } self.contentNodes = contentNodes @@ -281,6 +388,126 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod self.contentNodes.forEach(self.contentContainerNode.addSubnode) self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + + var unreadCountItems: [UnreadMessageCountsItem] = [] + unreadCountItems.append(.total(nil)) + var additionalPeerIds = Set() + for preset in presetList { + additionalPeerIds.formUnion(preset.additionallyIncludePeers) + } + if !additionalPeerIds.isEmpty { + for peerId in additionalPeerIds { + unreadCountItems.append(.peer(peerId)) + } + } + let unreadKey: PostboxViewKey = .unreadCounts(items: unreadCountItems) + var keys: [PostboxViewKey] = [] + keys.append(unreadKey) + for peerId in additionalPeerIds { + keys.append(.basicPeer(peerId)) + } + + self.countsDisposable = (context.account.postbox.combinedView(keys: keys) + |> deliverOnMainQueue).start(next: { [weak self] view in + guard let strongSelf = self else { + return + } + + if let unreadCounts = view.views[unreadKey] as? UnreadMessageCountsView { + var peerTagAndCount: [PeerId: (PeerSummaryCounterTags, Int)] = [:] + + var totalState: ChatListTotalUnreadState? + for entry in unreadCounts.entries { + switch entry { + case let .total(_, totalStateValue): + totalState = totalStateValue + case let .peer(peerId, state): + if let state = state, state.isUnread { + if let peerView = view.views[.basicPeer(peerId)] as? BasicPeerView, let peer = peerView.peer { + let tag = context.account.postbox.seedConfiguration.peerSummaryCounterTags(peer) + var peerCount = Int(state.count) + if state.isUnread { + peerCount = max(1, peerCount) + } + peerTagAndCount[peerId] = (tag, peerCount) + } + } + } + } + + var totalUnreadChatCount = 0 + if let totalState = totalState { + for (_, counters) in totalState.filteredCounters { + totalUnreadChatCount += Int(counters.chatCount) + } + } + + var shouldUpdateLayout = false + for case let contentNode as FilterItemNode in strongSelf.contentNodes { + let badgeString: String + if let preset = contentNode.preset { + var tags: [PeerSummaryCounterTags] = [] + if preset.includeCategories.contains(.privateChats) { + tags.append(.privateChat) + } + if preset.includeCategories.contains(.secretChats) { + tags.append(.secretChat) + } + if preset.includeCategories.contains(.privateGroups) { + tags.append(.privateGroup) + } + if preset.includeCategories.contains(.bots) { + tags.append(.bot) + } + if preset.includeCategories.contains(.publicGroups) { + tags.append(.publicGroup) + } + if preset.includeCategories.contains(.privateChats) { + tags.append(.channel) + } + + var count = 0 + if let totalState = totalState { + for tag in tags { + if preset.includeCategories.contains(.muted) { + } + if let value = totalState.filteredCounters[tag] { + count += Int(value.chatCount) + } + } + } + for peerId in preset.additionallyIncludePeers { + if let (tag, peerCount) = peerTagAndCount[peerId] { + if !tags.contains(tag) { + count += peerCount + } + } + } + if count != 0 { + badgeString = "\(count)" + } else { + badgeString = "" + } + } else { + badgeString = "" + } + if contentNode.updateBadge(text: badgeString) { + shouldUpdateLayout = true + } + } + + if shouldUpdateLayout { + if let layout = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, transition: .immediate) + } + } + } + + if !strongSelf.didSetIsReady { + strongSelf.didSetIsReady = true + strongSelf.isReady.set(.single(true)) + } + }) } deinit { @@ -290,6 +517,8 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod propertyAnimator?.stopAnimation(true) } } + + self.countsDisposable?.dispose() } func animateIn() { @@ -344,7 +573,7 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod } } - func animateOut(sourceNodes: [ASDisplayNode], completion: @escaping () -> Void) { + func animateOut(sourceNodes: [ASDisplayNode], fadeOutIcon: Bool, completion: @escaping () -> Void) { self.isUserInteractionEnabled = false var completedEffect = false @@ -408,7 +637,14 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod let sourceFrame = sourceNode.view.convert(sourceNode.bounds, to: self.view) self.contentContainerNode.layer.animateFrame(from: self.contentContainerNode.frame, to: sourceFrame, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue, removeOnCompletion: false) } - completedSourceNodes = true + if fadeOutIcon { + for snapshotView in self.snapshotViews { + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + completedSourceNodes = true + } else { + completedSourceNodes = true + } } func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { @@ -420,7 +656,7 @@ private final class TabBarChatListFilterControllerNode: ViewControllerTracingNod let sideInset: CGFloat = 18.0 var contentSize = CGSize() - contentSize.width = min(layout.size.width - 60.0, 220.0) + contentSize.width = min(layout.size.width - 40.0, 260.0) var applyNodes: [(ASDisplayNode, CGFloat, (CGFloat) -> Void)] = [] for itemNode in self.contentNodes { let (width, height, apply) = itemNode.updateLayout(maxWidth: contentSize.width - sideInset * 2.0) diff --git a/submodules/Postbox/Sources/ChatListView.swift b/submodules/Postbox/Sources/ChatListView.swift index 5c2ee9f22f..920d88b95b 100644 --- a/submodules/Postbox/Sources/ChatListView.swift +++ b/submodules/Postbox/Sources/ChatListView.swift @@ -297,7 +297,7 @@ private func updatedRenderedPeer(_ renderedPeer: RenderedPeer, updatedPeers: [Pe final class MutableChatListView { let groupId: PeerGroupId - let filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? + let filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? private let summaryComponents: ChatListEntrySummaryComponents fileprivate var additionalItemIds: Set fileprivate var additionalItemEntries: [MutableChatListEntry] @@ -307,7 +307,7 @@ final class MutableChatListView { fileprivate var groupEntries: [ChatListGroupReferenceEntry] private var count: Int - init(postbox: Postbox, groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)?, aroundIndex: ChatListIndex, count: Int, summaryComponents: ChatListEntrySummaryComponents) { + init(postbox: Postbox, groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)?, aroundIndex: ChatListIndex, count: Int, summaryComponents: ChatListEntrySummaryComponents) { let (entries, earlier, later) = postbox.fetchAroundChatEntries(groupId: groupId, index: aroundIndex, count: count, filterPredicate: filterPredicate) self.groupId = groupId @@ -496,8 +496,9 @@ final class MutableChatListView { if let filterPredicate = self.filterPredicate { for (peerId, settingsChange) in updatedPeerNotificationSettings { if let peer = postbox.peerTable.get(peerId) { - let wasIncluded = filterPredicate(peer, settingsChange.0) - let isIncluded = filterPredicate(peer, settingsChange.1) + let isUnread = postbox.readStateTable.getCombinedState(peerId)?.isUnread ?? false + let wasIncluded = filterPredicate(peer, settingsChange.0, isUnread) + let isIncluded = filterPredicate(peer, settingsChange.1, isUnread) if wasIncluded != isIncluded { if isIncluded { if let entry = postbox.chatListTable.getEntry(groupId: self.groupId, peerId: peerId, messageHistoryTable: postbox.messageHistoryTable, peerChatInterfaceStateTable: postbox.peerChatInterfaceStateTable) { @@ -664,7 +665,8 @@ final class MutableChatListView { switch initialEntry { case .IntermediateMessageEntry(let index, _, _, _), .MessageEntry(let index, _, _, _, _, _, _, _, _): if let peer = postbox.peerTable.get(index.messageIndex.id.peerId) { - if !filterPredicate(peer, postbox.peerNotificationSettingsTable.getEffective(index.messageIndex.id.peerId)) { + let isUnread = postbox.readStateTable.getCombinedState(index.messageIndex.id.peerId)?.isUnread ?? false + if !filterPredicate(peer, postbox.peerNotificationSettingsTable.getEffective(index.messageIndex.id.peerId), isUnread) { return false } } else { diff --git a/submodules/Postbox/Sources/MessageHistoryViewState.swift b/submodules/Postbox/Sources/MessageHistoryViewState.swift index 10dd22b484..731a0d4263 100644 --- a/submodules/Postbox/Sources/MessageHistoryViewState.swift +++ b/submodules/Postbox/Sources/MessageHistoryViewState.swift @@ -1,8 +1,13 @@ import Foundation -struct PeerIdAndNamespace: Hashable { - let peerId: PeerId - let namespace: MessageId.Namespace +public struct PeerIdAndNamespace: Hashable { + public let peerId: PeerId + public let namespace: MessageId.Namespace + + public init(peerId: PeerId, namespace: MessageId.Namespace) { + self.peerId = peerId + self.namespace = namespace + } } private func canContainHoles(_ peerIdAndNamespace: PeerIdAndNamespace, seedConfiguration: SeedConfiguration) -> Bool { diff --git a/submodules/Postbox/Sources/Postbox.swift b/submodules/Postbox/Sources/Postbox.swift index 8ce0ed2c6e..31548f7f4f 100644 --- a/submodules/Postbox/Sources/Postbox.swift +++ b/submodules/Postbox/Sources/Postbox.swift @@ -1362,11 +1362,14 @@ public final class Postbox { print("(Postbox initialization took \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") let _ = self.transaction({ transaction -> Void in - let reindexUnreadVersion: Int32 = 1 + let reindexUnreadVersion: Int32 = 2 if self.messageHistoryMetadataTable.getShouldReindexUnreadCountsState() != reindexUnreadVersion { self.messageHistoryMetadataTable.setShouldReindexUnreadCounts(value: true) self.messageHistoryMetadataTable.setShouldReindexUnreadCountsState(value: reindexUnreadVersion) } + #if DEBUG + self.messageHistoryMetadataTable.setShouldReindexUnreadCounts(value: true) + #endif if self.messageHistoryMetadataTable.shouldReindexUnreadCounts() { self.groupMessageStatsTable.removeAll() @@ -1650,12 +1653,13 @@ public final class Postbox { self.synchronizeGroupMessageStatsTable.set(groupId: groupId, namespace: namespace, needsValidation: false, operations: &self.currentUpdatedGroupSummarySynchronizeOperations) } - private func mappedChatListFilterPredicate(_ predicate: @escaping (Peer, PeerNotificationSettings?) -> Bool) -> (ChatListIntermediateEntry) -> Bool { + private func mappedChatListFilterPredicate(_ predicate: @escaping (Peer, PeerNotificationSettings?, Bool) -> Bool) -> (ChatListIntermediateEntry) -> Bool { return { entry in switch entry { case let .message(index, _, _): if let peer = self.peerTable.get(index.messageIndex.id.peerId) { - if predicate(peer, self.peerNotificationSettingsTable.getEffective(index.messageIndex.id.peerId)) { + let isUnread = self.readStateTable.getCombinedState(index.messageIndex.id.peerId)?.isUnread ?? false + if predicate(peer, self.peerNotificationSettingsTable.getEffective(index.messageIndex.id.peerId), isUnread) { return true } else { return false @@ -1669,7 +1673,7 @@ public final class Postbox { } } - func fetchAroundChatEntries(groupId: PeerGroupId, index: ChatListIndex, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)?) -> (entries: [MutableChatListEntry], earlier: MutableChatListEntry?, later: MutableChatListEntry?) { + func fetchAroundChatEntries(groupId: PeerGroupId, index: ChatListIndex, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)?) -> (entries: [MutableChatListEntry], earlier: MutableChatListEntry?, later: MutableChatListEntry?) { let mappedPredicate = filterPredicate.flatMap(self.mappedChatListFilterPredicate) let (intermediateEntries, intermediateLower, intermediateUpper) = self.chatListTable.entriesAround(groupId: groupId, index: index, messageHistoryTable: self.messageHistoryTable, peerChatInterfaceStateTable: self.peerChatInterfaceStateTable, count: count, predicate: mappedPredicate) let entries: [MutableChatListEntry] = intermediateEntries.map { entry in @@ -1685,7 +1689,7 @@ public final class Postbox { return (entries, lower, upper) } - func fetchEarlierChatEntries(groupId: PeerGroupId, index: ChatListIndex?, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)?) -> [MutableChatListEntry] { + func fetchEarlierChatEntries(groupId: PeerGroupId, index: ChatListIndex?, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)?) -> [MutableChatListEntry] { let mappedPredicate = filterPredicate.flatMap(self.mappedChatListFilterPredicate) let intermediateEntries = self.chatListTable.earlierEntries(groupId: groupId, index: index.flatMap({ ($0, true) }), messageHistoryTable: self.messageHistoryTable, peerChatInterfaceStateTable: self.peerChatInterfaceStateTable, count: count, predicate: mappedPredicate) let entries: [MutableChatListEntry] = intermediateEntries.map { entry in @@ -1694,7 +1698,7 @@ public final class Postbox { return entries } - func fetchLaterChatEntries(groupId: PeerGroupId, index: ChatListIndex?, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)?) -> [MutableChatListEntry] { + func fetchLaterChatEntries(groupId: PeerGroupId, index: ChatListIndex?, count: Int, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)?) -> [MutableChatListEntry] { let mappedPredicate = filterPredicate.flatMap(self.mappedChatListFilterPredicate) let intermediateEntries = self.chatListTable.laterEntries(groupId: groupId, index: index.flatMap({ ($0, true) }), messageHistoryTable: self.messageHistoryTable, peerChatInterfaceStateTable: self.peerChatInterfaceStateTable, count: count, predicate: mappedPredicate) let entries: [MutableChatListEntry] = intermediateEntries.map { entry in @@ -2542,11 +2546,11 @@ public final class Postbox { |> switchToLatest } - public func tailChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? = nil, count: Int, summaryComponents: ChatListEntrySummaryComponents) -> Signal<(ChatListView, ViewUpdateType), NoError> { + public func tailChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? = nil, count: Int, summaryComponents: ChatListEntrySummaryComponents) -> Signal<(ChatListView, ViewUpdateType), NoError> { return self.aroundChatListView(groupId: groupId, filterPredicate: filterPredicate, index: ChatListIndex.absoluteUpperBound, count: count, summaryComponents: summaryComponents, userInteractive: true) } - public func aroundChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? = nil, index: ChatListIndex, count: Int, summaryComponents: ChatListEntrySummaryComponents, userInteractive: Bool = false) -> Signal<(ChatListView, ViewUpdateType), NoError> { + public func aroundChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? = nil, index: ChatListIndex, count: Int, summaryComponents: ChatListEntrySummaryComponents, userInteractive: Bool = false) -> Signal<(ChatListView, ViewUpdateType), NoError> { return self.transactionSignal(userInteractive: userInteractive, { subscriber, transaction in let mutableView = MutableChatListView(postbox: self, groupId: groupId, filterPredicate: filterPredicate, aroundIndex: index, count: count, summaryComponents: summaryComponents) mutableView.render(postbox: self, renderMessage: self.renderIntermediateMessage, getPeer: { id in diff --git a/submodules/SettingsUI/Sources/Notifications/NotificationsAndSounds.swift b/submodules/SettingsUI/Sources/Notifications/NotificationsAndSounds.swift index 3db7091c49..f2ece41feb 100644 --- a/submodules/SettingsUI/Sources/Notifications/NotificationsAndSounds.swift +++ b/submodules/SettingsUI/Sources/Notifications/NotificationsAndSounds.swift @@ -17,6 +17,49 @@ import TelegramNotices import NotificationSoundSelectionUI import TelegramStringFormatting +private struct CounterTagSettings: OptionSet { + var rawValue: Int32 + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + init(summaryTags: PeerSummaryCounterTags) { + var result = CounterTagSettings() + if summaryTags.contains(.privateChat) { + result.insert(.regularChatsAndPrivateGroups) + } + if summaryTags.contains(.channel) { + result.insert(.channels) + } + if summaryTags.contains(.publicGroup) { + result.insert(.publicGroups) + } + self = result + } + + func toSumaryTags() -> PeerSummaryCounterTags { + var result = PeerSummaryCounterTags() + if self.contains(.regularChatsAndPrivateGroups) { + result.insert(.privateChat) + result.insert(.secretChat) + result.insert(.bot) + result.insert(.privateGroup) + } + if self.contains(.publicGroups) { + result.insert(.publicGroup) + } + if self.contains(.channels) { + result.insert(.channel) + } + return result + } + + static let regularChatsAndPrivateGroups = CounterTagSettings(rawValue: 1 << 0) + static let publicGroups = CounterTagSettings(rawValue: 1 << 1) + static let channels = CounterTagSettings(rawValue: 1 << 2) +} + private final class NotificationsAndSoundsArguments { let context: AccountContext let presentController: (ViewController, ViewControllerPresentationArguments?) -> Void @@ -43,7 +86,7 @@ private final class NotificationsAndSoundsArguments { let updateInAppPreviews: (Bool) -> Void let updateDisplayNameOnLockscreen: (Bool) -> Void - let updateIncludeTag: (PeerSummaryCounterTags, Bool) -> Void + let updateIncludeTag: (CounterTagSettings, Bool) -> Void let updateTotalUnreadCountCategory: (Bool) -> Void let updateJoinedNotifications: (Bool) -> Void @@ -56,7 +99,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, updateMessageAlerts: @escaping (Bool) -> Void, updateMessagePreviews: @escaping (Bool) -> Void, updateMessageSound: @escaping (PeerMessageSound) -> Void, updateGroupAlerts: @escaping (Bool) -> Void, updateGroupPreviews: @escaping (Bool) -> Void, updateGroupSound: @escaping (PeerMessageSound) -> Void, updateChannelAlerts: @escaping (Bool) -> Void, updateChannelPreviews: @escaping (Bool) -> Void, updateChannelSound: @escaping (PeerMessageSound) -> Void, updateInAppSounds: @escaping (Bool) -> Void, updateInAppVibration: @escaping (Bool) -> Void, updateInAppPreviews: @escaping (Bool) -> Void, updateDisplayNameOnLockscreen: @escaping (Bool) -> Void, updateIncludeTag: @escaping (PeerSummaryCounterTags, Bool) -> Void, updateTotalUnreadCountCategory: @escaping (Bool) -> Void, resetNotifications: @escaping () -> Void, updatedExceptionMode: @escaping(NotificationExceptionMode) -> 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, updateMessageAlerts: @escaping (Bool) -> Void, updateMessagePreviews: @escaping (Bool) -> Void, updateMessageSound: @escaping (PeerMessageSound) -> Void, updateGroupAlerts: @escaping (Bool) -> Void, updateGroupPreviews: @escaping (Bool) -> Void, updateGroupSound: @escaping (PeerMessageSound) -> Void, updateChannelAlerts: @escaping (Bool) -> Void, updateChannelPreviews: @escaping (Bool) -> Void, updateChannelSound: @escaping (PeerMessageSound) -> 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, updatedExceptionMode: @escaping(NotificationExceptionMode) -> Void, openAppSettings: @escaping () -> Void, updateJoinedNotifications: @escaping (Bool) -> Void, updateNotificationsFromAllAccounts: @escaping (Bool) -> Void) { self.context = context self.presentController = presentController self.pushController = pushController @@ -779,8 +822,11 @@ private func notificationsAndSoundsEntries(authorizationStatus: AccessType, warn entries.append(.displayNamesOnLockscreenInfo(presentationData.theme, presentationData.strings.Notifications_DisplayNamesOnLockScreenInfoWithLink)) entries.append(.badgeHeader(presentationData.theme, presentationData.strings.Notifications_Badge.uppercased())) - entries.append(.includePublicGroups(presentationData.theme, presentationData.strings.Notifications_Badge_IncludePublicGroups, inAppSettings.totalUnreadCountIncludeTags.contains(.publicGroups))) - entries.append(.includeChannels(presentationData.theme, presentationData.strings.Notifications_Badge_IncludeChannels, inAppSettings.totalUnreadCountIncludeTags.contains(.channels))) + + let counterTagSettings = CounterTagSettings(summaryTags: inAppSettings.totalUnreadCountIncludeTags) + + entries.append(.includePublicGroups(presentationData.theme, presentationData.strings.Notifications_Badge_IncludePublicGroups, counterTagSettings.contains(.publicGroups))) + entries.append(.includeChannels(presentationData.theme, presentationData.strings.Notifications_Badge_IncludeChannels, counterTagSettings.contains(.channels))) entries.append(.unreadCountCategory(presentationData.theme, presentationData.strings.Notifications_Badge_CountUnreadMessages, inAppSettings.totalUnreadCountDisplayCategory == .messages)) entries.append(.unreadCountCategoryInfo(presentationData.theme, inAppSettings.totalUnreadCountDisplayCategory == .chats ? presentationData.strings.Notifications_Badge_CountUnreadMessages_InfoOff : presentationData.strings.Notifications_Badge_CountUnreadMessages_InfoOn)) entries.append(.joinedNotifications(presentationData.theme, presentationData.strings.NotificationSettings_ContactJoined, globalSettings.contactsJoined)) @@ -911,12 +957,14 @@ public func notificationsAndSoundsController(context: AccountContext, exceptions }).start() }, updateIncludeTag: { tag, value in let _ = updateInAppNotificationSettingsInteractively(accountManager: context.sharedContext.accountManager, { settings in - var settings = settings + var currentSettings = CounterTagSettings(summaryTags: settings.totalUnreadCountIncludeTags) if !value { - settings.totalUnreadCountIncludeTags.remove(tag) + currentSettings.remove(tag) } else { - settings.totalUnreadCountIncludeTags.insert(tag) + currentSettings.insert(tag) } + var settings = settings + settings.totalUnreadCountIncludeTags = currentSettings.toSumaryTags() return settings }).start() }, updateTotalUnreadCountCategory: { value in diff --git a/submodules/SyncCore/Sources/Namespaces.swift b/submodules/SyncCore/Sources/Namespaces.swift index 96db1f9af2..5317234ea5 100644 --- a/submodules/SyncCore/Sources/Namespaces.swift +++ b/submodules/SyncCore/Sources/Namespaces.swift @@ -139,10 +139,44 @@ public struct OperationLogTags { public static let SynchronizeEmojiKeywords = PeerOperationLogTag(value: 19) } +public struct LegacyPeerSummaryCounterTags: OptionSet, Sequence, Hashable { + public var rawValue: Int32 + + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + public static let regularChatsAndPrivateGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 0) + public static let publicGroups = LegacyPeerSummaryCounterTags(rawValue: 1 << 1) + public static let channels = LegacyPeerSummaryCounterTags(rawValue: 1 << 2) + + public func makeIterator() -> AnyIterator { + var index = 0 + return AnyIterator { () -> LegacyPeerSummaryCounterTags? in + while index < 31 { + let currentTags = self.rawValue >> UInt32(index) + let tag = LegacyPeerSummaryCounterTags(rawValue: 1 << UInt32(index)) + index += 1 + if currentTags == 0 { + break + } + + if (currentTags & 1) != 0 { + return tag + } + } + return nil + } + } +} + public extension PeerSummaryCounterTags { - static let regularChatsAndPrivateGroups = PeerSummaryCounterTags(rawValue: 1 << 0) - static let publicGroups = PeerSummaryCounterTags(rawValue: 1 << 1) - static let channels = PeerSummaryCounterTags(rawValue: 1 << 2) + static let privateChat = PeerSummaryCounterTags(rawValue: 1 << 3) + static let secretChat = PeerSummaryCounterTags(rawValue: 1 << 4) + static let privateGroup = PeerSummaryCounterTags(rawValue: 1 << 5) + static let bot = PeerSummaryCounterTags(rawValue: 1 << 6) + static let channel = PeerSummaryCounterTags(rawValue: 1 << 7) + static let publicGroup = PeerSummaryCounterTags(rawValue: 1 << 8) } private enum PreferencesKeyValues: Int32 { diff --git a/submodules/SyncCore/Sources/StandaloneAccountTransaction.swift b/submodules/SyncCore/Sources/StandaloneAccountTransaction.swift index 93d625cb33..443881967e 100644 --- a/submodules/SyncCore/Sources/StandaloneAccountTransaction.swift +++ b/submodules/SyncCore/Sources/StandaloneAccountTransaction.swift @@ -19,19 +19,30 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { } return SeedConfiguration(globalMessageIdsPeerIdNamespaces: globalMessageIdsPeerIdNamespaces, initializeChatListWithHole: (topLevel: ChatListHole(index: MessageIndex(id: MessageId(peerId: PeerId(namespace: Namespaces.Peer.Empty, id: 0), namespace: Namespaces.Message.Cloud, id: 1), timestamp: Int32.max - 1)), groups: ChatListHole(index: MessageIndex(id: MessageId(peerId: PeerId(namespace: Namespaces.Peer.Empty, id: 0), namespace: Namespaces.Message.Cloud, id: 1), timestamp: Int32.max - 1))), messageHoles: messageHoles, existingMessageTags: MessageTags.all, messageTagsWithSummary: MessageTags.unseenPersonalMessage, existingGlobalMessageTags: GlobalMessageTags.all, peerNamespacesRequiringMessageTextIndex: [Namespaces.Peer.SecretChat], peerSummaryCounterTags: { peer in - if let peer = peer as? TelegramChannel { - switch peer.info { - case .group: - if let addressName = peer.username, !addressName.isEmpty { - return [.publicGroups] - } else { - return [.regularChatsAndPrivateGroups] - } - case .broadcast: - return [.channels] + if let peer = peer as? TelegramUser { + if peer.botInfo != nil { + return .bot + } else { + return .privateChat + } + } else if let _ = peer as? TelegramGroup { + return .privateGroup + } else if let _ = peer as? TelegramSecretChat { + return .secretChat + } else if let channel = peer as? TelegramChannel { + switch channel.info { + case .broadcast: + return .channel + case .group: + if channel.username != nil { + return .publicGroup + } else { + return .privateGroup + } } } else { - return [.regularChatsAndPrivateGroups] + assertionFailure() + return .privateChat } }, additionalChatListIndexNamespace: Namespaces.Message.Cloud, messageNamespacesRequiringGroupStatsValidation: [Namespaces.Message.Cloud], defaultMessageNamespaceReadStates: [Namespaces.Message.Local: .idBased(maxIncomingReadId: 0, maxOutgoingReadId: 0, maxKnownId: 0, count: 0, markedUnread: false)], chatMessagesNamespaces: Set([Namespaces.Message.Cloud, Namespaces.Message.Local, Namespaces.Message.SecretIncoming])) }() diff --git a/submodules/TelegramCore/Sources/AccountViewTracker.swift b/submodules/TelegramCore/Sources/AccountViewTracker.swift index 0d5ebe5678..23b6ecfb54 100644 --- a/submodules/TelegramCore/Sources/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/AccountViewTracker.swift @@ -1330,7 +1330,7 @@ public final class AccountViewTracker { }) } - public func tailChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? = nil, count: Int) -> Signal<(ChatListView, ViewUpdateType), NoError> { + public func tailChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? = nil, count: Int) -> Signal<(ChatListView, ViewUpdateType), NoError> { if let account = self.account { return self.wrappedChatListView(signal: account.postbox.tailChatListView(groupId: groupId, filterPredicate: filterPredicate, count: count, summaryComponents: ChatListEntrySummaryComponents(tagSummary: ChatListEntryMessageTagSummaryComponent(tag: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud), actionsSummary: ChatListEntryPendingMessageActionsSummaryComponent(type: PendingMessageActionType.consumeUnseenPersonalMessage, namespace: Namespaces.Message.Cloud)))) } else { @@ -1338,7 +1338,7 @@ public final class AccountViewTracker { } } - public func aroundChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?) -> Bool)? = nil, index: ChatListIndex, count: Int) -> Signal<(ChatListView, ViewUpdateType), NoError> { + public func aroundChatListView(groupId: PeerGroupId, filterPredicate: ((Peer, PeerNotificationSettings?, Bool) -> Bool)? = nil, index: ChatListIndex, count: Int) -> Signal<(ChatListView, ViewUpdateType), NoError> { if let account = self.account { return self.wrappedChatListView(signal: account.postbox.aroundChatListView(groupId: groupId, filterPredicate: filterPredicate, index: index, count: count, summaryComponents: ChatListEntrySummaryComponents(tagSummary: ChatListEntryMessageTagSummaryComponent(tag: .unseenPersonalMessage, namespace: Namespaces.Message.Cloud), actionsSummary: ChatListEntryPendingMessageActionsSummaryComponent(type: PendingMessageActionType.consumeUnseenPersonalMessage, namespace: Namespaces.Message.Cloud)))) } else { diff --git a/submodules/TelegramUI/TelegramUI/DeclareEncodables.swift b/submodules/TelegramUI/TelegramUI/DeclareEncodables.swift index 8de131f329..01c3ef9546 100644 --- a/submodules/TelegramUI/TelegramUI/DeclareEncodables.swift +++ b/submodules/TelegramUI/TelegramUI/DeclareEncodables.swift @@ -54,6 +54,7 @@ private var telegramUIDeclaredEncodables: Void = { declareEncodable(WebBrowserSettings.self, f: { WebBrowserSettings(decoder: $0) }) declareEncodable(IntentsSettings.self, f: { IntentsSettings(decoder: $0) }) declareEncodable(CachedGeocode.self, f: { CachedGeocode(decoder: $0) }) + declareEncodable(ChatListFilterSettings.self, f: { ChatListFilterSettings(decoder: $0) }) return }() diff --git a/submodules/TelegramUIPreferences/Sources/ChatListFilterSettings.swift b/submodules/TelegramUIPreferences/Sources/ChatListFilterSettings.swift new file mode 100644 index 0000000000..de6105939d --- /dev/null +++ b/submodules/TelegramUIPreferences/Sources/ChatListFilterSettings.swift @@ -0,0 +1,127 @@ +import Foundation +import Postbox +import SwiftSignalKit +import SyncCore + +public struct ChatListIncludeCategoryFilter: OptionSet { + public var rawValue: Int32 + + public init(rawValue: Int32) { + self.rawValue = rawValue + } + + public static let muted = ChatListIncludeCategoryFilter(rawValue: 1 << 1) + public static let privateChats = ChatListIncludeCategoryFilter(rawValue: 1 << 2) + public static let secretChats = ChatListIncludeCategoryFilter(rawValue: 1 << 3) + public static let privateGroups = ChatListIncludeCategoryFilter(rawValue: 1 << 4) + public static let bots = ChatListIncludeCategoryFilter(rawValue: 1 << 5) + public static let publicGroups = ChatListIncludeCategoryFilter(rawValue: 1 << 6) + public static let channels = ChatListIncludeCategoryFilter(rawValue: 1 << 7) + public static let read = ChatListIncludeCategoryFilter(rawValue: 1 << 8) + + public static let all: ChatListIncludeCategoryFilter = [ + .muted, + .privateChats, + .secretChats, + .privateGroups, + .bots, + .publicGroups, + .channels, + .read + ] +} + +public enum ChatListFilterPresetName: Equatable, Hashable, PostboxCoding { + case unread + case custom(String) + + public init(decoder: PostboxDecoder) { + switch decoder.decodeInt32ForKey("_t", orElse: 0) { + case 0: + self = .unread + case 1: + self = .custom(decoder.decodeStringForKey("title", orElse: "Preset")) + default: + assertionFailure() + self = .custom("Preset") + } + } + + public func encode(_ encoder: PostboxEncoder) { + switch self { + case .unread: + encoder.encodeInt32(0, forKey: "_t") + case let .custom(title): + encoder.encodeInt32(1, forKey: "_t") + encoder.encodeString(title, forKey: "title") + } + } +} + +public struct ChatListFilterPreset: Equatable, PostboxCoding { + public var name: ChatListFilterPresetName + public var includeCategories: ChatListIncludeCategoryFilter + public var additionallyIncludePeers: [PeerId] + + public init(name: ChatListFilterPresetName, includeCategories: ChatListIncludeCategoryFilter, additionallyIncludePeers: [PeerId]) { + self.name = name + self.includeCategories = includeCategories + self.additionallyIncludePeers = additionallyIncludePeers + } + + public init(decoder: PostboxDecoder) { + self.name = decoder.decodeObjectForKey("name", decoder: { ChatListFilterPresetName(decoder: $0) }) as? ChatListFilterPresetName ?? ChatListFilterPresetName.custom("Preset") + self.includeCategories = ChatListIncludeCategoryFilter(rawValue: decoder.decodeInt32ForKey("includeCategories", orElse: 0)) + self.additionallyIncludePeers = decoder.decodeInt64ArrayForKey("additionallyIncludePeers").map(PeerId.init) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeObject(self.name, forKey: "name") + encoder.encodeInt32(self.includeCategories.rawValue, forKey: "includeCategories") + encoder.encodeInt64Array(self.additionallyIncludePeers.map { $0.toInt64() }, forKey: "additionallyIncludePeers") + } +} + +public struct ChatListFilterSettings: PreferencesEntry, Equatable { + public var presets: [ChatListFilterPreset] + + public static var `default`: ChatListFilterSettings { + return ChatListFilterSettings(presets: [ + ChatListFilterPreset( + name: .unread, + includeCategories: ChatListIncludeCategoryFilter.all.subtracting(.read), + additionallyIncludePeers: [] + ) + ]) + } + + public init(presets: [ChatListFilterPreset]) { + self.presets = presets + } + + public init(decoder: PostboxDecoder) { + self.presets = decoder.decodeObjectArrayWithDecoderForKey("presets") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeObjectArray(self.presets, forKey: "presets") + } + + public func isEqual(to: PreferencesEntry) -> Bool { + if let to = to as? ChatListFilterSettings { + return self == to + } else { + return false + } + } +} + +public func updateChatListFilterSettingsInteractively(postbox: Postbox, _ f: @escaping (ChatListFilterSettings) -> ChatListFilterSettings) -> Signal { + return postbox.transaction { transaction -> Void in + transaction.updatePreferencesEntry(key: ApplicationSpecificPreferencesKeys.chatListFilterSettings, { entry in + var settings = entry as? ChatListFilterSettings ?? ChatListFilterSettings.default + return f(settings) + }) + } + |> ignoreValues +} diff --git a/submodules/TelegramUIPreferences/Sources/InAppNotificationSettings.swift b/submodules/TelegramUIPreferences/Sources/InAppNotificationSettings.swift index e1c47d10f7..3da4f51362 100644 --- a/submodules/TelegramUIPreferences/Sources/InAppNotificationSettings.swift +++ b/submodules/TelegramUIPreferences/Sources/InAppNotificationSettings.swift @@ -1,6 +1,7 @@ import Foundation import Postbox import SwiftSignalKit +import SyncCore public enum TotalUnreadCountDisplayStyle: Int32 { case filtered = 0 @@ -38,7 +39,7 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { public var displayNotificationsFromAllAccounts: Bool public static var defaultSettings: InAppNotificationSettings { - return InAppNotificationSettings(playSounds: true, vibrate: false, displayPreviews: true, totalUnreadCountDisplayStyle: .filtered, totalUnreadCountDisplayCategory: .messages, totalUnreadCountIncludeTags: [.regularChatsAndPrivateGroups], displayNameOnLockscreen: true, displayNotificationsFromAllAccounts: true) + return InAppNotificationSettings(playSounds: true, vibrate: false, displayPreviews: true, totalUnreadCountDisplayStyle: .filtered, totalUnreadCountDisplayCategory: .messages, totalUnreadCountIncludeTags: [.privateChat, .secretChat, .bot, .privateGroup], displayNameOnLockscreen: true, displayNotificationsFromAllAccounts: true) } public init(playSounds: Bool, vibrate: Bool, displayPreviews: Bool, totalUnreadCountDisplayStyle: TotalUnreadCountDisplayStyle, totalUnreadCountDisplayCategory: TotalUnreadCountDisplayCategory, totalUnreadCountIncludeTags: PeerSummaryCounterTags, displayNameOnLockscreen: Bool, displayNotificationsFromAllAccounts: Bool) { @@ -58,10 +59,25 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { self.displayPreviews = decoder.decodeInt32ForKey("p", orElse: 0) != 0 self.totalUnreadCountDisplayStyle = TotalUnreadCountDisplayStyle(rawValue: decoder.decodeInt32ForKey("cds", orElse: 0)) ?? .filtered self.totalUnreadCountDisplayCategory = TotalUnreadCountDisplayCategory(rawValue: decoder.decodeInt32ForKey("totalUnreadCountDisplayCategory", orElse: 1)) ?? .messages - if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags") { + if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags_2") { self.totalUnreadCountIncludeTags = PeerSummaryCounterTags(rawValue: value) + } else if let value = decoder.decodeOptionalInt32ForKey("totalUnreadCountIncludeTags") { + var resultTags: PeerSummaryCounterTags = [] + for legacyTag in LegacyPeerSummaryCounterTags(rawValue: value) { + if legacyTag == .regularChatsAndPrivateGroups { + resultTags.insert(.privateChat) + resultTags.insert(.secretChat) + resultTags.insert(.bot) + resultTags.insert(.privateGroup) + } else if legacyTag == .publicGroups { + resultTags.insert(.publicGroup) + } else if legacyTag == .channels { + resultTags.insert(.channel) + } + } + self.totalUnreadCountIncludeTags = resultTags } else { - self.totalUnreadCountIncludeTags = [.regularChatsAndPrivateGroups] + self.totalUnreadCountIncludeTags = [.privateChat, .secretChat, .bot, .privateGroup] } self.displayNameOnLockscreen = decoder.decodeInt32ForKey("displayNameOnLockscreen", orElse: 1) != 0 self.displayNotificationsFromAllAccounts = decoder.decodeInt32ForKey("displayNotificationsFromAllAccounts", orElse: 1) != 0 @@ -73,7 +89,7 @@ public struct InAppNotificationSettings: PreferencesEntry, Equatable { encoder.encodeInt32(self.displayPreviews ? 1 : 0, forKey: "p") encoder.encodeInt32(self.totalUnreadCountDisplayStyle.rawValue, forKey: "cds") encoder.encodeInt32(self.totalUnreadCountDisplayCategory.rawValue, forKey: "totalUnreadCountDisplayCategory") - encoder.encodeInt32(self.totalUnreadCountIncludeTags.rawValue, forKey: "totalUnreadCountIncludeTags") + encoder.encodeInt32(self.totalUnreadCountIncludeTags.rawValue, forKey: "totalUnreadCountIncludeTags_2") encoder.encodeInt32(self.displayNameOnLockscreen ? 1 : 0, forKey: "displayNameOnLockscreen") encoder.encodeInt32(self.displayNotificationsFromAllAccounts ? 1 : 0, forKey: "displayNotificationsFromAllAccounts") } diff --git a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift index 52ac48e6e7..17f712a5e0 100644 --- a/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift +++ b/submodules/TelegramUIPreferences/Sources/PostboxKeys.swift @@ -6,11 +6,13 @@ import Postbox private enum ApplicationSpecificPreferencesKeyValues: Int32 { case voipDerivedState = 16 case chatArchiveSettings = 17 + case chatListFilterSettings = 18 } public struct ApplicationSpecificPreferencesKeys { public static let voipDerivedState = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.voipDerivedState.rawValue) public static let chatArchiveSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.chatArchiveSettings.rawValue) + public static let chatListFilterSettings = applicationSpecificPreferencesKey(ApplicationSpecificPreferencesKeyValues.chatListFilterSettings.rawValue) } private enum ApplicationSpecificSharedDataKeyValues: Int32 {