// // NotificationsCollectionViewController.swift // Tusker // // Created by Shadowfacts on 5/6/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine #if canImport(Sentry) import Sentry #endif class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { weak var mastodonController: MastodonController! private let filterer: Filterer private let allowedTypes: [Pachyderm.Notification.Kind] private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow] private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private(set) var collectionView: UICollectionView! private(set) var dataSource: UICollectionViewDiffableDataSource! var reconfigureVisibleItemsOnEndDecelerating: Bool = false private var newer: RequestRange? private var older: RequestRange? init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) { self.allowedTypes = allowedTypes self.mastodonController = mastodonController self.filterer = Filterer(mastodonController: mastodonController, context: .notifications, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter) super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self)) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications")) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in let dismissAction = UIContextualAction(style: .destructive, title: "Dismiss") { _, _, completion in Task { await self.dismissNotificationsInGroup(at: indexPath) completion(true) } } dismissAction.accessibilityLabel = "Dismiss Notification" dismissAction.image = UIImage(systemName: "clear.fill") let cellConfig = (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() let config = UISwipeActionsConfiguration(actions: (cellConfig?.actions ?? []) + [dismissAction]) config.performsFirstActionWithFullSwipe = cellConfig?.performsFirstActionWithFullSwipe ?? false return config } config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return sectionSeparatorConfiguration } var config = sectionSeparatorConfiguration if item.hidesSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else if case .group(_, _, .some(let filterState)) = item, self.filterer.isKnownHide(state: filterState) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) section.readableContentInset(in: environment) return section } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) registerTimelineLikeCells() dataSource = createDataSource() #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif filterer.filtersChanged = { [unowned self] actionsChanged in self.reapplyFilters(actionsChanged: actionsChanged) } NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self let statusID = itemIdentifier.0.notifications.first!.status!.id let statusState = itemIdentifier.1 cell.updateUI(statusID: statusID, state: statusState, filterResult: itemIdentifier.2, precomputedContent: itemIdentifier.3) } let actionGroupCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(group: itemIdentifier) } let followCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(group: itemIdentifier) } let followRequestCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(notification: itemIdentifier) } let pollCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(notification: itemIdentifier) } let updateCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(notification: itemIdentifier) } let unknownCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in var config = cell.defaultContentConfiguration() config.text = "Unknown Notification" cell.contentConfiguration = config } let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .group(let group, let collapseState, let filterState): switch group.kind { case .status, .mention: let (result, precomputedContent) = self.filterer.resolve(state: filterState!) { let id = group.notifications.first!.status!.id return (self.mastodonController.persistentContainer.status(for: id)!, false) } switch result { case .allow, .warn(_): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (group, collapseState!, result, precomputedContent)) case .hide: return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ()) } case .favourite, .reblog: return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) case .follow: return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) case .followRequest: return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!) case .poll: return collectionView.dequeueConfiguredReusableCell(using: pollCell, for: indexPath, item: group.notifications.first!) case .update: return collectionView.dequeueConfiguredReusableCell(using: updateCell, for: indexPath, item: group.notifications.first!) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) } case .loadingIndicator: return self.loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return self.confirmLoadMoreCell(for: indexPath) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .notLoadedInitial = controller.state { Task { await controller.loadInitial() } } } @objc func refresh() { Task { @MainActor in if case .notLoadedInitial = controller.state { await controller.loadInitial() } else { await controller.loadNewer() } #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() #endif } } private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) { let status = { let status = self.mastodonController.persistentContainer.status(for: statusID)! // if the status is a reblog of another one, filter based on that one if let reblogged = status.reblog { return (reblogged, true) } else { return (status, false) } } return filterer.resolve(state: state, status: status) } private func reapplyFilters(actionsChanged: Bool) { let visible = collectionView.indexPathsForVisibleItems let items = visible .compactMap { dataSource.itemIdentifier(for: $0) } .filter { if case .group(_, _, .some(_)) = $0 { return true } else { return false } } guard !items.isEmpty else { return } var snapshot = dataSource.snapshot() if actionsChanged { snapshot.reloadItems(items) } else { snapshot.reconfigureItems(items) } dataSource.apply(snapshot) } @objc private func handleStatusDeleted(_ notification: Foundation.Notification) { guard let userInfo = notification.userInfo, let statusIDs = userInfo["statusIDs"] as? [String] else { return } var snapshot = dataSource.snapshot() guard snapshot.sectionIdentifiers.contains(.notifications) else { return } let items = snapshot.itemIdentifiers(inSection: .notifications) let toDelete = statusIDs.flatMap { id in items.lazy.filter { $0.group?.notifications.first?.status?.id == id } } if !toDelete.isEmpty { snapshot.deleteItems(toDelete) dataSource.apply(snapshot, animatingDifferences: true) } } private func dismissNotificationsInGroup(at indexPath: IndexPath) async { guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else { return } let notifications = group.notifications let dismissFailedIndices = await withTaskGroup(of: (Int, Bool).self) { group -> [Int] in for (index, notification) in notifications.enumerated() { group.addTask { do { _ = try await self.mastodonController.run(Notification.dismiss(id: notification.id)) return (index, true) } catch { return (index, false) } } } return await group.reduce(into: [], { partialResult, value in if !value.1 { partialResult.append(value.0) } }) } var snapshot = dataSource.snapshot() if dismissFailedIndices.isEmpty { snapshot.deleteItems([.group(group, collapseState, filterState)]) } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] } snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState)) snapshot.deleteItems([.group(group, collapseState, filterState)]) } await apply(snapshot, animatingDifferences: true) } } extension NotificationsCollectionViewController { enum Section: TimelineLikeCollectionViewSection { case notifications case footer static var entries: Self { .notifications } } enum Item: TimelineLikeCollectionViewItem { case group(NotificationGroup, CollapseState?, FilterState?) case loadingIndicator case confirmLoadMore static func fromTimelineItem(_ item: NotificationGroup) -> Self { switch item.kind { case .mention, .status: return .group(item, .unknown, .unknown) default: return .group(item, nil, nil) } } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.group(let a, _, _), .group(let b, _, _)): return a == b case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .group(let group, _, _): hasher.combine(0) hasher.combine(group) case .loadingIndicator: hasher.combine(1) case .confirmLoadMore: hasher.combine(1) } } var group: NotificationGroup? { if case .group(let group, _, _) = self { return group } else { return nil } } var isSelectable: Bool { switch self { case .group(_, _, _): return true default: return false } } var hidesSeparators: Bool { switch self { case .loadingIndicator, .confirmLoadMore: return true default: return false } } } } // MARK: TimelineLikeControllerDelegate extension NotificationsCollectionViewController { typealias TimelineItem = NotificationGroup private static let pageSize = 40 private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> { if mastodonController.instanceFeatures.notificationsAllowedTypes { return Client.getNotifications(allowedTypes: allowedTypes, range: range) } else { var types = Set(Notification.Kind.allCases) types.remove(.unknown) allowedTypes.forEach { types.remove($0) } if !mastodonController.instanceFeatures.statusEditNotifications { types.remove(.update) } if !mastodonController.instanceFeatures.statusNotifications { types.remove(.status) } return Client.getNotifications(excludedTypes: Array(types), range: range) } } private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] { return notifications.compactMap { notif in if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite || notif.kind == .status) { #if canImport(Sentry) let crumb = Breadcrumb(level: .fatal, category: "notifications") crumb.data = [ "id": notif.id, "type": notif.kind.rawValue, "created_at": notif.createdAt.formatted(.iso8601), "account": notif.account.id, ] SentrySDK.addBreadcrumb(crumb) #endif return nil } else { return notif } } } func loadInitial() async throws -> [NotificationGroup] { let request = self.request(range: .count(NotificationsCollectionViewController.pageSize)) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize) self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } return NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) } func loadNewer() async throws -> [NotificationGroup] { guard let newer else { throw Error.noNewer } let request = self.request(range: newer) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) guard !validated.isEmpty else { throw Error.allCaughtUp } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } let newerGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group) return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes) } func handlePrependItems(_ timelineItems: [NotificationGroup]) async { let topItem = dataSource.snapshot().itemIdentifiers(inSection: .notifications).first // we always replace all, because new items are merged with existing ones await handleReplaceAllItems(timelineItems) // preserve the scroll position // todo: this won't work for cmd+r when not at top if let topID = topItem?.group?.notifications.first?.id { // the exact item may have changed, due to merging let newTopGroup = timelineItems.first { $0.notifications.contains { $0.id == topID } }! if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) { collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false) } } } func loadOlder() async throws -> [NotificationGroup] { guard let older else { throw Error.noOlder } let request = self.request(range: older) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } let olderGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group) return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes) } func handleAppendItems(_ timelineItems: [NotificationGroup]) async { await handleReplaceAllItems(timelineItems) } enum Error: TimelineLikeCollectionViewError { case noNewer case noOlder case allCaughtUp } } extension NotificationsCollectionViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else { return } let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) if indexPath.row == itemsInSection - 1 { Task { // Because of grouping, all cells from the first load may fit on screen, // in which case, we try to load older while still in the loadingInitial state. // So, wait for that to finish before trying to load more. await controller.finishPendingOperation() await controller.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath), case .group(let group, let collapseState, let filterState) = item else { return } switch group.kind { case .mention, .status, .poll, .update: if let filterState, filterState.isWarning == true { filterer.setResult(.allow, for: filterState) collectionView.deselectItem(at: indexPath, animated: true) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) } else { let statusID = group.notifications.first!.status!.id let state = collapseState?.copy() ?? .unknown selected(status: statusID, state: state) } case .favourite, .reblog: let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog let statusID = group.notifications.first!.status!.id let accountIDs = group.notifications.map(\.account.id) let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController) show(vc) case .follow: let accountIDs = group.notifications.map(\.account.id) switch accountIDs.count { case 0: collectionView.deselectItem(at: indexPath, animated: true) case 1: selected(account: accountIDs.first!) default: let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController) vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title") show(vc) } case .followRequest: selected(account: group.notifications.first!.account.id) case .unknown: collectionView.deselectItem(at: indexPath, animated: true) } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case .group(let group, let collapseState, _) = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) else { return nil } switch group.kind { case .mention, .status, .poll, .update: guard let statusID = group.notifications.first?.status?.id, let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } let state = collapseState?.copy() ?? .unknown return UIContextMenuConfiguration { ConversationViewController(for: statusID, state: state, mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update)) } case .favourite, .reblog: return UIContextMenuConfiguration(previewProvider: { let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog let statusID = group.notifications.first!.status!.id let accountIDs = group.notifications.map(\.account.id) return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController) }) case .follow: let accountIDs = group.notifications.map(\.account.id) return UIContextMenuConfiguration { if accountIDs.count == 1 { return ProfileViewController(accountID: accountIDs.first!, mastodonController: self.mastodonController) } else { let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: self.mastodonController) vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title") return vc } } actionProvider: { _ in if accountIDs.count == 1 { return UIMenu(children: self.actionsForProfile(accountID: accountIDs.first!, source: .view(cell))) } else { return nil } } case .followRequest: let accountID = group.notifications.first!.account.id return UIContextMenuConfiguration { ProfileViewController(accountID: accountID, mastodonController: self.mastodonController) } actionProvider: { _ in let cell = cell as! FollowRequestNotificationCollectionViewCell let acceptRejectChildren = [ UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), ] let acceptRejectMenu: UIMenu if #available(iOS 16.0, *) { acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren) } else { acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren) } return UIMenu(children: [ acceptRejectMenu, UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), ]) } case .unknown: return nil } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { if reconfigureVisibleItemsOnEndDecelerating { reconfigureVisibleItemsOnEndDecelerating = false reconfigureVisibleCells() } } #if os(visionOS) func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { return self.collectionView(collectionView, shouldSelectItemAt: indexPath) } #endif } extension NotificationsCollectionViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard case .group(let group, _, _) = dataSource.itemIdentifier(for: indexPath) else { return [] } switch group.kind { case .mention, .status: // not combiend with .poll and .update below, b/c TimelineStatusCollectionViewCell handles checking whether the poll view is tracking let cell = collectionView.cellForItem(at: indexPath) as! TimelineStatusCollectionViewCell return cell.dragItemsForBeginning(session: session) case .poll, .update: let status = group.notifications.first!.status! let provider = NSItemProvider(object: URL(status.url!)! as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] case .favourite, .reblog: return [] case .follow, .followRequest: guard group.notifications.count == 1 else { return [] } let account = group.notifications.first!.account let provider = NSItemProvider(object: account.url as NSURL) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] case .unknown: return [] } } } extension NotificationsCollectionViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension NotificationsCollectionViewController: MenuActionProvider { } extension NotificationsCollectionViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { if let indexPath = collectionView.indexPath(for: cell), let item = dataSource.itemIdentifier(for: indexPath), case .group(_, _, .some(let filterState)) = item { filterer.setResult(.allow, for: filterState) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) } } }