import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences import ItemListUI import PresentationDataUtils import AccountContext import AlertUI import PresentationDataUtils import NotificationSoundSelectionUI import TelegramStringFormatting import ItemListPeerItem import ItemListPeerActionItem import SettingsUI import NotificationPeerExceptionController private extension EnginePeer.NotificationSettings.MuteState { var timeInterval: Int32? { switch self { case .default: return nil case .unmuted: return 0 case let .muted(until): return until } } } private func filteredGlobalSound(_ sound: PeerMessageSound) -> PeerMessageSound { if case .default = sound { return defaultCloudPeerNotificationSound } else { return sound } } private final class NotificationsPeerCategoryControllerArguments { let context: AccountContext let soundSelectionDisposable: MetaDisposable let updateEnabled: (Bool) -> Void let updatePreviews: (Bool) -> Void let openSound: (PeerMessageSound) -> Void let addException: () -> Void let openException: (Int64) -> Void let removeAllExceptions: () -> Void let updateRevealedThreadId: (Int64?) -> Void let removeThread: (Int64) -> Void init(context: AccountContext, soundSelectionDisposable: MetaDisposable, updateEnabled: @escaping (Bool) -> Void, updatePreviews: @escaping (Bool) -> Void, openSound: @escaping (PeerMessageSound) -> Void, addException: @escaping () -> Void, openException: @escaping (Int64) -> Void, removeAllExceptions: @escaping () -> Void, updateRevealedThreadId: @escaping (Int64?) -> Void, removeThread: @escaping (Int64) -> Void) { self.context = context self.soundSelectionDisposable = soundSelectionDisposable self.updateEnabled = updateEnabled self.updatePreviews = updatePreviews self.openSound = openSound self.addException = addException self.openException = openException self.removeAllExceptions = removeAllExceptions self.updateRevealedThreadId = updateRevealedThreadId self.removeThread = removeThread } } private enum NotificationsPeerCategorySection: Int32 { case enable case options case exceptions } private enum NotificationsPeerCategoryEntryTag: ItemListItemTag { case enable case previews case sound public func isEqual(to other: ItemListItemTag) -> Bool { if let other = other as? NotificationsPeerCategoryEntryTag, self == other { return true } else { return false } } } private enum NotificationsPeerCategoryEntry: ItemListNodeEntry { case enable(String, Bool) case optionsHeader(String) case previews(String, Bool) case sound(String, String, PeerMessageSound) case exceptionsHeader(String) case addException(String) case exception(Int32, PresentationDateTimeFormat, PresentationPersonNameOrder, EnginePeer, Int64, EngineMessageHistoryThread.Info, String, TelegramPeerNotificationSettings, Bool, Bool) case removeAllExceptions(String) var section: ItemListSectionId { switch self { case .enable: return NotificationsPeerCategorySection.enable.rawValue case .optionsHeader, .previews, .sound: return NotificationsPeerCategorySection.options.rawValue case .exceptionsHeader, .addException, .exception, .removeAllExceptions: return NotificationsPeerCategorySection.exceptions.rawValue } } var stableId: Int32 { switch self { case .enable: return 0 case .optionsHeader: return 1 case .previews: return 2 case .sound: return 3 case .exceptionsHeader: return 4 case .addException: return 5 case let .exception(index, _, _, _, _, _, _, _, _, _): return 6 + index case .removeAllExceptions: return 100000 } } var tag: ItemListItemTag? { switch self { case .enable: return NotificationsPeerCategoryEntryTag.enable case .previews: return NotificationsPeerCategoryEntryTag.previews case .sound: return NotificationsPeerCategoryEntryTag.sound default: return nil } } static func ==(lhs: NotificationsPeerCategoryEntry, rhs: NotificationsPeerCategoryEntry) -> Bool { switch lhs { case let .enable(lhsText, lhsValue): if case let .enable(rhsText, rhsValue) = rhs, lhsText == rhsText, lhsValue == rhsValue { return true } else { return false } case let .optionsHeader(lhsText): if case let .optionsHeader(rhsText) = rhs, lhsText == rhsText { return true } else { return false } case let .previews(lhsText, lhsValue): if case let .previews(rhsText, rhsValue) = rhs, lhsText == rhsText, lhsValue == rhsValue { return true } else { return false } case let .sound(lhsText, lhsValue, lhsSound): if case let .sound(rhsText, rhsValue, rhsSound) = rhs, lhsText == rhsText, lhsValue == rhsValue, lhsSound == rhsSound { return true } else { return false } case let .exceptionsHeader(lhsText): if case let .exceptionsHeader(rhsText) = rhs, lhsText == rhsText { return true } else { return false } case let .addException(lhsText): if case let .addException(rhsText) = rhs, lhsText == rhsText { return true } else { return false } case let .exception(lhsIndex, lhsDateTimeFormat, lhsDisplayNameOrder, lhsPeer, lhsThreadId, lhsInfo, lhsDescription, lhsSettings, lhsEditing, lhsRevealed): if case let .exception(rhsIndex, rhsDateTimeFormat, rhsDisplayNameOrder, rhsPeer, rhsThreadId, rhsInfo, rhsDescription, rhsSettings, rhsEditing, rhsRevealed) = rhs, lhsIndex == rhsIndex, lhsDateTimeFormat == rhsDateTimeFormat, lhsDisplayNameOrder == rhsDisplayNameOrder, lhsPeer == rhsPeer, lhsThreadId == rhsThreadId, lhsInfo == rhsInfo, lhsDescription == rhsDescription, lhsSettings == rhsSettings, lhsEditing == rhsEditing, lhsRevealed == rhsRevealed { return true } else { return false } case let .removeAllExceptions(lhsText): if case let .removeAllExceptions(rhsText) = rhs, lhsText == rhsText { return true } else { return false } } } static func <(lhs: NotificationsPeerCategoryEntry, rhs: NotificationsPeerCategoryEntry) -> Bool { return lhs.stableId < rhs.stableId } func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { let arguments = arguments as! NotificationsPeerCategoryControllerArguments switch self { case let .enable(text, value): return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { updatedValue in arguments.updateEnabled(updatedValue) }, tag: self.tag) case let .optionsHeader(text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .previews(text, value): return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in arguments.updatePreviews(value) }) case let .sound(text, value, sound): return ItemListDisclosureItem(presentationData: presentationData, title: text, label: value, labelStyle: .text, sectionId: self.section, style: .blocks, disclosureStyle: .arrow, action: { arguments.openSound(sound) }, tag: self.tag) case let .exceptionsHeader(text): return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) case let .addException(text): return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.plusIconImage(presentationData.theme), title: text, sectionId: self.section, height: .peerList, color: .accent, editing: false, action: { arguments.addException() }) case let .exception(_, dateTimeFormat, nameDisplayOrder, peer, threadId, info, description, _, editing, revealed): return ItemListPeerItem(presentationData: presentationData, dateTimeFormat: dateTimeFormat, nameDisplayOrder: nameDisplayOrder, context: arguments.context, peer: peer, threadInfo: info, presence: nil, text: .text(description, .secondary), label: .none, editing: ItemListPeerItemEditing(editable: true, editing: editing, revealed: revealed), switchValue: nil, enabled: true, selectable: true, sectionId: self.section, action: { arguments.openException(threadId) }, setPeerIdWithRevealedOptions: { peerId, fromPeerId in if let _ = peerId { arguments.updateRevealedThreadId(threadId) } else { arguments.updateRevealedThreadId(nil) } }, removePeer: { _ in arguments.removeThread(threadId) }, hasTopStripe: false, hasTopGroupInset: false, noInsets: false) case let .removeAllExceptions(text): return ItemListPeerActionItem(presentationData: presentationData, icon: PresentationResourcesItemList.deleteIconImage(presentationData.theme), title: text, sectionId: self.section, height: .generic, color: .destructive, editing: false, action: { arguments.removeAllExceptions() }) } } } private func notificationsPeerCategoryEntries(peerId: EnginePeer.Id, notificationSettings: EnginePeer.NotificationSettings, state: NotificationExceptionState, presentationData: PresentationData, notificationSoundList: NotificationSoundList?) -> [NotificationsPeerCategoryEntry] { var entries: [NotificationsPeerCategoryEntry] = [] var notificationsEnabled = true if case .muted = notificationSettings.muteState { notificationsEnabled = false } var displayPreviews = true switch notificationSettings.displayPreviews { case .hide: displayPreviews = false default: break } entries.append(.enable(presentationData.strings.Notifications_MessageNotificationsAlert, notificationsEnabled)) if notificationsEnabled || !state.notificationExceptions.isEmpty { entries.append(.optionsHeader(presentationData.strings.Notifications_Options.uppercased())) entries.append(.previews(presentationData.strings.Notifications_MessageNotificationsPreview, displayPreviews)) entries.append(.sound(presentationData.strings.Notifications_MessageNotificationsSound, localizedPeerNotificationSoundString(strings: presentationData.strings, notificationSoundList: notificationSoundList, sound: filteredGlobalSound(notificationSettings.messageSound._asMessageSound())), filteredGlobalSound(notificationSettings.messageSound._asMessageSound()))) } entries.append(.exceptionsHeader(presentationData.strings.Notifications_MessageNotificationsExceptions.uppercased())) entries.append(.addException(presentationData.strings.Notification_Exceptions_AddException)) var existingThreadIds = Set() var index: Int = 0 for value in state.notificationExceptions { var title: String var muted = false switch value.notificationSettings.muteState { case let .muted(until): if until >= Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) { if until < Int32.max - 1 { let formatter = DateFormatter() formatter.locale = Locale(identifier: presentationData.strings.baseLanguageCode) if Calendar.current.isDateInToday(Date(timeIntervalSince1970: Double(until))) { formatter.dateFormat = "HH:mm" } else { formatter.dateFormat = "E, d MMM HH:mm" } let dateString = formatter.string(from: Date(timeIntervalSince1970: Double(until))) title = presentationData.strings.Notification_Exceptions_MutedUntil(dateString).string } else { muted = true title = presentationData.strings.Notification_Exceptions_AlwaysOff } } else { title = presentationData.strings.Notification_Exceptions_AlwaysOn } case .unmuted: title = presentationData.strings.Notification_Exceptions_AlwaysOn default: title = "" } if !muted { switch value.notificationSettings.messageSound { case .default: break default: if !title.isEmpty { title.append(", ") } title.append(presentationData.strings.Notification_Exceptions_SoundCustom) } switch value.notificationSettings.displayPreviews { case .default: break default: if !title.isEmpty { title += ", " } if case .show = value.notificationSettings.displayPreviews { title += presentationData.strings.Notification_Exceptions_PreviewAlwaysOn } else { title += presentationData.strings.Notification_Exceptions_PreviewAlwaysOff } } } existingThreadIds.insert(value.threadId) entries.append(.exception(Int32(index), presentationData.dateTimeFormat, presentationData.nameDisplayOrder, .channel(TelegramChannel(id: peerId, accessHash: nil, title: "", username: nil, photo: [], creationDate: 0, version: 0, participationStatus: .member, info: .group(TelegramChannelGroupInfo(flags: [])), flags: [.isForum], restrictionInfo: nil, adminRights: nil, bannedRights: nil, defaultBannedRights: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, emojiStatus: nil, approximateBoostLevel: nil, subscriptionUntilDate: nil)), value.threadId, value.info, title, value.notificationSettings._asNotificationSettings(), state.editing, state.revealedThreadId == value.threadId)) index += 1 } if state.notificationExceptions.count > 0 { entries.append(.removeAllExceptions(presentationData.strings.Notifications_DeleteAllExceptions)) } return entries } private extension EnginePeer.NotificationSettings { var isDefault: Bool { switch self.muteState { case .default: break case .muted, .unmuted: return false } switch self.messageSound { case .default: break case .none, .bundledClassic, .bundledModern, .cloud: return false } switch self.displayPreviews { case .default: break case .hide, .show: return false } return true } } private struct NotificationExceptionState: Equatable { var revealedThreadId: Int64? = nil var editing: Bool = false var notificationExceptions: [EngineMessageHistoryThread.NotificationException] = [] mutating func updateSound(threadId: Int64, info: EngineMessageHistoryThread.Info, sound: PeerMessageSound) { if let index = self.notificationExceptions.firstIndex(where: { $0.threadId == threadId }) { self.notificationExceptions[index].notificationSettings.messageSound = EnginePeer.NotificationSettings.MessageSound(sound) if self.notificationExceptions[index].notificationSettings.isDefault { self.notificationExceptions.remove(at: index) } } else { var settings = EnginePeer.NotificationSettings(.defaultSettings) settings.messageSound = EnginePeer.NotificationSettings.MessageSound(sound) if !settings.isDefault { notificationExceptions.insert(EngineMessageHistoryThread.NotificationException(threadId: threadId, info: info, notificationSettings: settings), at: 0) } } } mutating func updateMuteInterval(threadId: Int64, info: EngineMessageHistoryThread.Info, muteInterval: Int32?) { if let index = self.notificationExceptions.firstIndex(where: { $0.threadId == threadId }) { self.notificationExceptions[index].notificationSettings.muteState = muteInterval.flatMap { .muted(until: $0) } ?? .unmuted if self.notificationExceptions[index].notificationSettings.isDefault { self.notificationExceptions.remove(at: index) } } else { var settings = EnginePeer.NotificationSettings(.defaultSettings) settings.muteState = muteInterval.flatMap { .muted(until: $0) } ?? .unmuted if !settings.isDefault { notificationExceptions.insert(EngineMessageHistoryThread.NotificationException(threadId: threadId, info: info, notificationSettings: settings), at: 0) } } } mutating func updateDisplayPreviews(threadId: Int64, info: EngineMessageHistoryThread.Info, displayPreviews: PeerNotificationDisplayPreviews) { if let index = self.notificationExceptions.firstIndex(where: { $0.threadId == threadId }) { self.notificationExceptions[index].notificationSettings.displayPreviews = EnginePeer.NotificationSettings.DisplayPreviews(displayPreviews) if self.notificationExceptions[index].notificationSettings.isDefault { self.notificationExceptions.remove(at: index) } } else { var settings = EnginePeer.NotificationSettings(.defaultSettings) settings.displayPreviews = EnginePeer.NotificationSettings.DisplayPreviews(displayPreviews) if !settings.isDefault { notificationExceptions.insert(EngineMessageHistoryThread.NotificationException(threadId: threadId, info: info, notificationSettings: settings), at: 0) } } } } public func threadNotificationExceptionsScreen(context: AccountContext, peerId: EnginePeer.Id, notificationExceptions: [EngineMessageHistoryThread.NotificationException], updated: @escaping ([EngineMessageHistoryThread.NotificationException]) -> Void) -> ViewController { var presentControllerImpl: ((ViewController, Any?) -> Void)? var pushControllerImpl: ((ViewController) -> Void)? let initialState = NotificationExceptionState(notificationExceptions: notificationExceptions) let stateValue = Atomic(value: initialState) let statePromise: ValuePromise = ValuePromise(ignoreRepeated: true) statePromise.set(initialState) let updateState: ((NotificationExceptionState) -> NotificationExceptionState) -> Void = { f in let result = stateValue.modify { f($0) } statePromise.set(result) } let updateThreadSound: (Int64, PeerMessageSound) -> Signal = { threadId, sound in return context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: threadId, sound: sound) |> deliverOnMainQueue } let updateThreadNotificationInterval: (Int64, Int32?) -> Signal = { threadId, muteInterval in return context.engine.peers.updatePeerMuteSetting(peerId: peerId, threadId: threadId, muteInterval: muteInterval) |> deliverOnMainQueue } let updateThreadDisplayPreviews: (Int64, PeerNotificationDisplayPreviews) -> Signal = { threadId, displayPreviews in return context.engine.peers.updatePeerDisplayPreviewsSetting(peerId: peerId, threadId: threadId, displayPreviews: displayPreviews) |> deliverOnMainQueue } let presentThreadSettings: (EngineMessageHistoryThread.NotificationException, @escaping () -> Void) -> Void = { item, completion in let _ = (context.engine.data.get( TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), TelegramEngine.EngineData.Item.NotificationSettings.Global() ) |> deliverOnMainQueue).start(next: { peer, globalSettings in completion() guard let peer = peer else { return } let canRemove = true let defaultSound: PeerMessageSound = globalSettings.groupChats.sound._asMessageSound() pushControllerImpl?(notificationPeerExceptionController(context: context, peer: peer, customTitle: item.info.title, threadId: item.threadId, isStories: nil, canRemove: canRemove, defaultSound: defaultSound, defaultStoriesSound: defaultSound, updatePeerSound: { _, sound in let _ = (updateThreadSound(item.threadId, sound) |> deliverOnMainQueue).start(next: { _ in updateState { value in var value = value value.updateSound(threadId: item.threadId, info: item.info, sound: sound) return value } updated(stateValue.with({ $0 }).notificationExceptions) }) }, updatePeerNotificationInterval: { _, muteInterval in let _ = (updateThreadNotificationInterval(item.threadId, muteInterval) |> deliverOnMainQueue).start(next: { _ in updateState { value in var value = value value.updateMuteInterval(threadId: item.threadId, info: item.info, muteInterval: muteInterval) return value } updated(stateValue.with({ $0 }).notificationExceptions) }) }, updatePeerDisplayPreviews: { _, displayPreviews in let _ = (updateThreadDisplayPreviews(item.threadId, displayPreviews) |> deliverOnMainQueue).start(next: { _ in updateState { value in var value = value value.updateDisplayPreviews(threadId: item.threadId, info: item.info, displayPreviews: displayPreviews) return value } updated(stateValue.with({ $0 }).notificationExceptions) }) }, updatePeerStoriesMuted: { _, _ in }, updatePeerStoriesHideSender: { _, _ in }, updatePeerStorySound: { _, _ in }, removePeerFromExceptions: { let _ = context.engine.peers.removeCustomThreadNotificationSettings(peerId: peerId, threadIds: [item.threadId]).start() updateState { current in var current = current current.notificationExceptions.removeAll(where: { $0.threadId == item.threadId }) return current } updated(stateValue.with({ $0 }).notificationExceptions) }, modifiedPeer: { })) }) } let _ = presentControllerImpl let arguments = NotificationsPeerCategoryControllerArguments(context: context, soundSelectionDisposable: MetaDisposable(), updateEnabled: { _ in let _ = context.engine.peers.togglePeerMuted(peerId: peerId, threadId: nil).start() }, updatePreviews: { value in let _ = context.engine.peers.updatePeerDisplayPreviewsSetting(peerId: peerId, threadId: nil, displayPreviews: value ? .show : .hide).start() }, openSound: { sound in let controller = notificationSoundSelectionController(context: context, isModal: true, currentSound: sound, defaultSound: nil, completion: { value in let _ = context.engine.peers.updatePeerNotificationSoundInteractive(peerId: peerId, threadId: nil, sound: value).start() }) pushControllerImpl?(controller) }, addException: { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let filter: ChatListNodePeersFilter = [.excludeRecent, .doNotSearchMessages, .removeSearchHeader] let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(context: context, filter: filter, forumPeerId: peerId, hasContactSelector: false, title: presentationData.strings.Notifications_AddExceptionTitle)) controller.peerSelected = { [weak controller] _, threadId in guard let threadId = threadId else { return } let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.ThreadData(id: peerId, threadId: threadId)) |> deliverOnMainQueue).start(next: { threadData in guard let threadData = threadData else { return } presentThreadSettings(EngineMessageHistoryThread.NotificationException(threadId: threadId, info: threadData.info, notificationSettings: EnginePeer.NotificationSettings(.defaultSettings)), { controller?.dismiss() }) }) } pushControllerImpl?(controller) }, openException: { threadId in if let item = stateValue.with({ $0 }).notificationExceptions.first(where: { $0.threadId == threadId }) { presentThreadSettings(item, {}) } }, removeAllExceptions: { let presentationData = context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(presentationData: presentationData) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: presentationData.strings.Notification_Exceptions_DeleteAllConfirmation), ActionSheetButtonItem(title: presentationData.strings.Notification_Exceptions_DeleteAll, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() var threadIds: [Int64] = [] updateState { current in var current = current threadIds = current.notificationExceptions.map(\.threadId) current.notificationExceptions.removeAll() return current } updated(stateValue.with({ $0 }).notificationExceptions) let _ = context.engine.peers.removeCustomThreadNotificationSettings(peerId: peerId, threadIds: threadIds).start() }) ]), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) presentControllerImpl?(actionSheet, nil) }, updateRevealedThreadId: { threadId in updateState { current in var current = current current.revealedThreadId = threadId return current } }, removeThread: { threadId in let _ = context.engine.peers.removeCustomThreadNotificationSettings(peerId: peerId, threadIds: [threadId]).start() updateState { current in var current = current current.notificationExceptions.removeAll(where: { $0.threadId == threadId }) return current } updated(stateValue.with({ $0 }).notificationExceptions) }) let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, context.engine.peers.notificationSoundList(), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: peerId)), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.NotificationSettings(id: peerId)), statePromise.get() ) |> map { presentationData, notificationSoundList, peer, notificationSettings, state -> (ItemListControllerState, (ItemListNodeState, Any)) in let entries = notificationsPeerCategoryEntries(peerId: peerId, notificationSettings: notificationSettings, state: state, presentationData: presentationData, notificationSoundList: notificationSoundList) var scrollToItem: ListViewScrollToItem? scrollToItem = nil /*var index = 0 if let focusOnItemTag = focusOnItemTag { for entry in entries { if entry.tag?.isEqual(to: focusOnItemTag) ?? false { scrollToItem = ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Up) } index += 1 } }*/ let leftNavigationButton: ItemListNavigationButton? let rightNavigationButton: ItemListNavigationButton? if !state.notificationExceptions.isEmpty { if state.editing { leftNavigationButton = ItemListNavigationButton(content: .none, style: .regular, enabled: false, action: {}) rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Done), style: .bold, enabled: true, action: { updateState { value in var value = value value.editing = false return value } }) } else { leftNavigationButton = nil rightNavigationButton = ItemListNavigationButton(content: .text(presentationData.strings.Common_Edit), style: .regular, enabled: true, action: { updateState { value in var value = value value.editing = true return value } }) } } else { leftNavigationButton = nil rightNavigationButton = nil } let title: String if let peer = peer { title = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) } else { title = "" } let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text(title), leftNavigationButton: leftNavigationButton, rightNavigationButton: rightNavigationButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true) let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: nil, initialScrollToItem: scrollToItem) return (controllerState, (listState, arguments)) } let controller = ItemListController(context: context, state: signal) presentControllerImpl = { [weak controller] c, a in controller?.present(c, in: .window(.root), with: a) } pushControllerImpl = { [weak controller] c in (controller?.navigationController as? NavigationController)?.pushViewController(c) } return controller }