diff --git a/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSectionIndexTests.swift b/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSectionIndexTests.swift deleted file mode 100644 index 3641a5a5a90e..000000000000 --- a/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSectionIndexTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import XCTest -@testable import WordPress - -class BlogDetailsSectionIndexTests: XCTestCase { - func testFindingExistingSectionIndex() { - let blogDetailsViewController = BlogDetailsViewController() - let sections = [ - BlogDetailsSection(title: nil, andRows: [], category: .general), - BlogDetailsSection(title: nil, andRows: [], category: .domainCredit) - ] - let sectionIndex = blogDetailsViewController.findSectionIndex(sections: sections, category: .general) - XCTAssertEqual(sectionIndex, 0) - } - - func testFindingNonExistingSectionIndex() { - let blogDetailsViewController = BlogDetailsViewController() - let sections = [ - BlogDetailsSection(title: nil, andRows: [], category: .general), - BlogDetailsSection(title: nil, andRows: [], category: .domainCredit) - ] - let sectionIndex = blogDetailsViewController.findSectionIndex(sections: sections, category: .external) - XCTAssertEqual(sectionIndex, NSNotFound) - } - - func testFindingSectionIndexFromEmptySections() { - let blogDetailsViewController = BlogDetailsViewController() - let sectionIndex = blogDetailsViewController.findSectionIndex(sections: [], category: .external) - XCTAssertEqual(sectionIndex, NSNotFound) - } -} diff --git a/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSubsectionToSectionCategoryTests.swift b/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSubsectionToSectionCategoryTests.swift deleted file mode 100644 index 3d9140902503..000000000000 --- a/Tests/KeystoneTests/Tests/Features/Blog/BlogDetailsSubsectionToSectionCategoryTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import XCTest -@testable import WordPress -@testable import WordPressData - -class BlogDetailsSubsectionToSectionCategoryTests: CoreDataTestCase { - var blog: Blog! - - override func setUp() { - blog = BlogBuilder(contextManager.mainContext).build() - } - - func testEachSubsectionToSectionCategory() { - let blogDetailsViewController = BlogDetailsViewController() - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .domainCredit, blog: blog), .domainCredit) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .stats, blog: blog), .general) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .activity, blog: blog), .jetpack) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .pages, blog: blog), .content) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .posts, blog: blog), .content) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .media, blog: blog), .content) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .comments, blog: blog), .content) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .themes, blog: blog), .personalize) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .customize, blog: blog), .personalize) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .sharing, blog: blog), .configure) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .people, blog: blog), .configure) - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .plugins, blog: blog), .configure) - } - - func testEachSubsectionToSectionCategoryForJetpack() { - let blogDetailsViewController = BlogDetailsViewController() - let blog = BlogBuilder(contextManager.mainContext) - .set(blogOption: "is_wpforteams_site", value: false) - .withAnAccount() - .with(isAdmin: true) - .build() - - XCTAssertEqual(blogDetailsViewController.sectionCategory(subsection: .stats, blog: blog), .jetpack) - } -} diff --git a/WordPress/Classes/Apps/Reader/ReaderRootViewPresenter.swift b/WordPress/Classes/Apps/Reader/ReaderRootViewPresenter.swift index 48a3771262b1..8caf7d83e3bb 100644 --- a/WordPress/Classes/Apps/Reader/ReaderRootViewPresenter.swift +++ b/WordPress/Classes/Apps/Reader/ReaderRootViewPresenter.swift @@ -18,7 +18,7 @@ final class ReaderRootViewPresenter: RootViewPresenter { // TODO: (reader) optional? } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) { // TODO: (reader) optional? } diff --git a/WordPress/Classes/System/Root View/RootViewPresenter.swift b/WordPress/Classes/System/Root View/RootViewPresenter.swift index 8d8e8cb0c7ed..dd5ca0c38952 100644 --- a/WordPress/Classes/System/Root View/RootViewPresenter.swift +++ b/WordPress/Classes/System/Root View/RootViewPresenter.swift @@ -9,7 +9,7 @@ protocol RootViewPresenter: AnyObject { func currentlyVisibleBlog() -> Blog? func showMySitesTab() - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) func showReader(path: ReaderNavigationPath?) @@ -28,7 +28,7 @@ extension RootViewPresenter { showBlogDetails(for: blog, then: nil, userInfo: [:]) } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind) { showBlogDetails(for: blog, then: subsection, userInfo: [:]) } @@ -47,7 +47,7 @@ extension RootViewPresenter { } var userInfo: [AnyHashable: Any] = [:] if let source { - userInfo[BlogDetailsViewController.userInfoSourceKey()] = NSNumber(value: source.rawValue) + userInfo[BlogDetailsUserInfoKeys.source] = NSNumber(value: source.rawValue) } showBlogDetails(for: blog, then: .stats) } diff --git a/WordPress/Classes/System/Root View/SplitViewRootPresenter+Site.swift b/WordPress/Classes/System/Root View/SplitViewRootPresenter+Site.swift index 5862d913cab0..f9acaa5df7d5 100644 --- a/WordPress/Classes/System/Root View/SplitViewRootPresenter+Site.swift +++ b/WordPress/Classes/System/Root View/SplitViewRootPresenter+Site.swift @@ -49,7 +49,7 @@ class SiteSplitViewContent: SiteMenuViewControllerDelegate, SplitViewDisplayable } } - func showSubsection(_ subsection: BlogDetailsSubsection, userInfo: [AnyHashable: Any]) { + func showSubsection(_ subsection: BlogDetailsRowKind, userInfo: [String: Any]) { siteMenuVC.showSubsection(subsection, userInfo: userInfo) } } diff --git a/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift b/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift index f3bc50161616..fc1ca988e09d 100644 --- a/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift +++ b/WordPress/Classes/System/Root View/SplitViewRootPresenter.swift @@ -241,7 +241,7 @@ final class SplitViewRootPresenter: RootViewPresenter { return siteContent?.blog } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) { if splitVC.isCollapsed { tabBarVC.showBlogDetails(for: blog, then: subsection, userInfo: userInfo) } else { diff --git a/WordPress/Classes/System/Root View/StaticScreensTabBarWrapper.swift b/WordPress/Classes/System/Root View/StaticScreensTabBarWrapper.swift index 5a6acdf99aba..c10532a6d667 100644 --- a/WordPress/Classes/System/Root View/StaticScreensTabBarWrapper.swift +++ b/WordPress/Classes/System/Root View/StaticScreensTabBarWrapper.swift @@ -20,7 +20,7 @@ class StaticScreensTabBarWrapper: RootViewPresenter { tabBarController.currentlySelectedScreen() } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) { tabBarController.showBlogDetails(for: blog, then: subsection, userInfo: userInfo) } diff --git a/WordPress/Classes/System/Root View/WPTabBarController+RootViewPresenter.swift b/WordPress/Classes/System/Root View/WPTabBarController+RootViewPresenter.swift index d8a7ddb9bb49..76cad8d9d5c0 100644 --- a/WordPress/Classes/System/Root View/WPTabBarController+RootViewPresenter.swift +++ b/WordPress/Classes/System/Root View/WPTabBarController+RootViewPresenter.swift @@ -12,7 +12,7 @@ extension WPTabBarController: RootViewPresenter { return self } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) { mySitesCoordinator.showBlogDetails(for: blog, then: subsection, userInfo: userInfo) } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift b/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift index 27f6fcc5c16a..f1ad41d6c679 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift @@ -128,7 +128,7 @@ extension MySitesRoute: NavigationAction { presenter.showBlogDetails(for: blog, then: .plugins) case .managePlugins: presenter.showBlogDetails(for: blog, then: .plugins, userInfo: [ - BlogDetailsViewController.userInfoShowManagemenetScreenKey(): true + BlogDetailsUserInfoKeys.showManagePlugins: true ]) case .siteMonitoring: presenter.showSiteMonitoring(for: blog, selectedTab: .metrics) @@ -143,13 +143,13 @@ extension MySitesRoute: NavigationAction { private extension RootViewPresenter { func showMediaPicker(for blog: Blog) { showBlogDetails(for: blog, then: .media, userInfo: [ - BlogDetailsViewController.userInfoShowPickerKey(): true + BlogDetailsUserInfoKeys.showPicker: true ]) } func showSiteMonitoring(for blog: Blog, selectedTab: SiteMonitoringTab) { showBlogDetails(for: blog, then: .siteMonitoring, userInfo: [ - BlogDetailsViewController.userInfoSiteMonitoringTabKey(): selectedTab.rawValue + BlogDetailsUserInfoKeys.siteMonitoringTab: selectedTab.rawValue ]) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index 3504546d8815..e65ad1cf2cb2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -137,7 +137,7 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab // MARK: - DashboardQuickActionsCardCell (BlogDetailsPresentationDelegate) extension DashboardQuickActionsCardCell: BlogDetailsPresentationDelegate { - func showBlogDetailsSubsection(_ subsection: BlogDetailsSubsection) { + func showBlogDetailsSubsection(_ subsection: BlogDetailsRowKind) { self.blogDetailsViewController?.showDetailView(for: subsection) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift new file mode 100644 index 000000000000..24f757dd3471 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -0,0 +1,1309 @@ +import Foundation +import UIKit +import WordPressLegacy +import WordPressShared +import WordPressSharedObjC +import WordPressUI + +private struct Section { + let title: String? + let rows: [Row] + let footerTitle: String? + let category: SectionCategory + + init( + title: String? = nil, + rows: [Row], + footerTitle: String? = nil, + category: SectionCategory + ) { + self.title = title + self.rows = rows + self.footerTitle = footerTitle + self.category = category + } +} + +@objc public final class BlogDetailsTableViewModel: NSObject { + private var blog: Blog + private weak var tableView: UITableView? + private weak var viewController: BlogDetailsViewController? + private var sections: [Section] = [] + + var restorableSelectedRow: BlogDetailsRowKind? { + didSet { + if let row = restorableSelectedRow, + let section = sections.first(where: { $0.rows.contains { $0.kind == row } }), + [.jetpackBrandingCard, .domainCredit].contains(section.category) { + restorableSelectedRow = nil + } + } + } + + var restorableSelectedIndexPath: IndexPath? { + restorableSelectedRow.flatMap(indexPath(for:)) + } + + var gravatarIcon: UIImage? { + didSet { + if let indexPath = self.indexPath(for: .me) { + tableView?.reloadRows(at: [indexPath], with: .automatic) + } + } + } + + var useSiteMenuStyle = false + + @objc public init(blog: Blog, viewController: BlogDetailsViewController) { + self.blog = blog + self.viewController = viewController + super.init() + } + + @objc public func configure(tableView: UITableView) { + self.tableView = tableView + + // Register standard cells + tableView.register(WPTableViewCell.self, forCellReuseIdentifier: CellIdentifiers.standard) + tableView.register(WPTableViewCellValue1.self, forCellReuseIdentifier: CellIdentifiers.plan) + tableView.register(WPTableViewCellValue1.self, forCellReuseIdentifier: CellIdentifiers.settings) + tableView.register(WPTableViewCell.self, forCellReuseIdentifier: CellIdentifiers.removeSite) + + // Register header/footer views + tableView.register(BlogDetailsSectionFooterView.self, forHeaderFooterViewReuseIdentifier: CellIdentifiers.sectionFooter) + + // Register special card cells + tableView.register(MigrationSuccessCell.self, forCellReuseIdentifier: CellIdentifiers.migrationSuccess) + tableView.register(JetpackBrandingMenuCardCell.self, forCellReuseIdentifier: CellIdentifiers.jetpackBrandingCard) + tableView.register(JetpackRemoteInstallTableViewCell.self, forCellReuseIdentifier: CellIdentifiers.jetpackInstall) + tableView.register(SotWTableViewCell.self, forCellReuseIdentifier: CellIdentifiers.sotWCard) + + tableView.delegate = self + tableView.dataSource = self + } + + @objc public func viewWillAppear() { + if !isSplitViewDisplayed { + restorableSelectedRow = nil + } + } + + @objc public func configureTableViewData() { + guard let viewController else { return } + + var newSections: [Section] = [] + + if viewController.shouldShowSotW2023Card() { + newSections.append(Section(rows: [], category: .sotW2023Card)) + } + + if viewController.shouldShowJetpackInstallCard() { + newSections.append(Section(rows: [], category: .jetpackInstallCard)) + } + + if viewController.shouldShowTopJetpackBrandingMenuCard { + newSections.append(Section(rows: [], category: .jetpackBrandingCard)) + } + + if viewController.isDashboardEnabled() && isSplitViewDisplayed { + newSections.append(buildHomeSection()) + } + + if AppConfiguration.isWordPress { + if viewController.shouldAddJetpackSection() { + newSections.append(buildJetpackSection()) + } + + if viewController.shouldAddGeneralSection() { + newSections.append(buildGeneralSection()) + } + + newSections.append(buildPublishTypeSection()) + + if viewController.shouldAddPersonalizeSection() { + newSections.append(buildPersonalizeSection()) + } + + newSections.append(buildConfigurationSection()) + newSections.append(buildExternalSection()) + } else { + newSections.append(buildContentSection()) + + if let trafficSection = buildTrafficSection() { + newSections.append(trafficSection) + } + + newSections.append(contentsOf: buildMaintenanceSections()) + } + + if blog.supports(.removable) { + newSections.append(buildRemoveSiteSection()) + } + + if viewController.shouldShowBottomJetpackBrandingMenuCard { + newSections.append(Section(rows: [], category: .jetpackBrandingCard)) + } + + sections = newSections + } + + private var isSplitViewDisplayed: Bool { + viewController?.isSidebarModeEnabled ?? false + } + + func defaultSubsection() -> BlogDetailsRowKind { + if !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + return .posts + } + if let viewController, viewController.isDashboardEnabled() { + return .home + } + return .stats + } + + func optimumScrollPosition(for indexPath: IndexPath) -> UITableView.ScrollPosition { + guard let tableView, !isSplitViewDisplayed else { return .none } + + let cellRect = tableView.rectForRow(at: indexPath) + return CGRectContainsRect(tableView.bounds, cellRect) ? .none : .middle + } + + @objc public func reloadTableViewPreservingSelection(_ tableView: UITableView) { + tableView.reloadData() + + if isSplitViewDisplayed, let indexPath = restorableSelectedIndexPath { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: optimumScrollPosition(for: indexPath)) + sections[indexPath.section].rows[indexPath.row].action?([:]) + } + } + + @objc public func showInitialDetailsForBlog() { + guard isSplitViewDisplayed else { return } + + let row = defaultSubsection() + self.restorableSelectedRow = row + + self.showDetailView(for: row) + } + + @objc func numberOfSections() -> Int { + sections.count + } + + func showDetailViewForMe(userInfo: [String: Any]) -> MeViewController { + guard let viewController else { + wpAssertionFailure("The view controller should not be nil") + return MeViewController() + } + restorableSelectedRow = .me + return viewController.showMe() + } + + func showDetailView(for row: BlogDetailsRowKind, userInfo: [String: Any] = [:]) { + for (sectionIndex, section) in sections.enumerated() { + for (rowIndex, rowItem) in section.rows.enumerated() where rowItem.kind == row { + let indexPath = IndexPath(row: rowIndex, section: sectionIndex) + + if rowItem.showsSelectionState { + restorableSelectedRow = row + + tableView?.selectRow(at: indexPath, animated: false, scrollPosition: optimumScrollPosition(for: indexPath)) + } + + // Call the row's action + rowItem.action?(userInfo) + return + } + } + } + + func indexPath(for row: BlogDetailsRowKind) -> IndexPath? { + for (sectionIndex, section) in sections.enumerated() { + for (rowIndex, rowItem) in section.rows.enumerated() where rowItem.kind == row { + return IndexPath(row: rowIndex, section: sectionIndex) + } + } + return nil + } +} + +extension BlogDetailsTableViewModel: UITableViewDataSource { + public func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard section < sections.count else { return 0 } + + switch sections[section].category { + case .sotW2023Card, .jetpackInstallCard, .migrationSuccess, .jetpackBrandingCard: + // The "card" sections do not set the `rows` property. It's hard-coded to show specific types of cards. + wpAssert(sections[section].rows.count == 0) + return 1 + default: + return sections[section].rows.count + } + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard indexPath.section < sections.count else { + return UITableViewCell() + } + + let section = sections[indexPath.section] + let cell: UITableViewCell + + switch section.category { + case .sotW2023Card: + cell = configureSotWCell(tableView: tableView) + case .jetpackInstallCard: + cell = configureJetpackInstallCell(tableView: tableView) + case .migrationSuccess: + cell = configureMigrationSuccessCell(tableView: tableView) + case .jetpackBrandingCard: + cell = configureJetpackBrandingCell(tableView: tableView) + default: + if indexPath.row < section.rows.count { + let row = section.rows[indexPath.row] + cell = configureStandardCell(tableView: tableView, indexPath: indexPath, row: row) + } else { + cell = UITableViewCell() + } + } + + if useSiteMenuStyle { + configureForDisplayingOnSiteMenu(cell) + } + + return cell + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard section < sections.count else { return nil } + return sections[section].title + } + + private func configureForDisplayingOnSiteMenu(_ cell: UITableViewCell) { + cell.textLabel?.font = .preferredFont(forTextStyle: .body) + cell.backgroundColor = .clear + cell.selectedBackgroundView = { + let backgroundView = UIView() + backgroundView.backgroundColor = .secondarySystemFill + backgroundView.layer.cornerRadius = DesignConstants.radius(.large) + backgroundView.layer.cornerCurve = .continuous + + let container = UIView() + container.addSubview(backgroundView) + backgroundView.pinEdges(insets: UIEdgeInsets(.horizontal, 16)) + return container + }() + cell.focusStyle = .custom + cell.focusEffect = nil + } +} + +extension BlogDetailsTableViewModel: UITableViewDelegate { + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section < sections.count else { return } + let section = sections[indexPath.section] + + guard indexPath.row < section.rows.count else { return } + let row = section.rows[indexPath.row] + + row.action?([:]) + + if row.showsSelectionState { + restorableSelectedRow = row.kind + } else { + if !isSplitViewDisplayed { + tableView.deselectRow(at: indexPath, animated: true) + } else if let indexPath = restorableSelectedIndexPath { + tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + } + } + } + + public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + let isNewSelection = (indexPath != tableView.indexPathForSelectedRow) + return isNewSelection ? indexPath : nil + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard section < sections.count else { return 0 } + let detailSection = sections[section] + let isLastSection = section == sections.count - 1 + let hasTitle = !(detailSection.footerTitle?.isEmpty ?? true) + + if hasTitle { + return UITableView.automaticDimension + } + if isLastSection { + return 40.0 + } + return 0 + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + guard section < sections.count else { return 0 } + let detailSection = sections[section] + let hasTitle = !(detailSection.title?.isEmpty ?? true) + + if useSiteMenuStyle { + return hasTitle ? 48 : 0 + } + + return hasTitle ? 40.0 : 20.0 + } + + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard useSiteMenuStyle else { return nil } + + guard let title = self.tableView(tableView, titleForHeaderInSection: section) else { return nil } + + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .headline) + label.text = title + + let headerView = UIView() + headerView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20), + label.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -8), + label.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: 20) + ]) + return headerView + } + + public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard section < sections.count, + let footerTitle = sections[section].footerTitle, + !footerTitle.isEmpty else { + return nil + } + + guard let footerView = tableView.dequeueReusableHeaderFooterView( + withIdentifier: CellIdentifiers.sectionFooterIdentifier + ) as? BlogDetailsSectionFooterView else { + return nil + } + + let shouldShowExtraSpacing = (section + 1 < sections.count) && (sections[section + 1].title != nil) + footerView.updateUI(title: footerTitle, shouldShowExtraSpacing: shouldShowExtraSpacing) + return footerView + } +} + +private extension BlogDetailsTableViewModel { + func configureStandardCell( + tableView: UITableView, + indexPath: IndexPath, + row: Row + ) -> UITableViewCell { + let identifier = switch row.kind { + case .removeSite: + CellIdentifiers.removeSite + case .jetpackSettings, .siteSettings, .domain: + CellIdentifiers.settings + default: + CellIdentifiers.standard + } + let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) + + cell.accessibilityHint = row.accessibilityHint + cell.accessoryView = nil + cell.textLabel?.textAlignment = .natural + + if row.kind == .removeSite { + cell.accessoryType = .none + WPStyleGuide.configureTableViewDestructiveActionCell(cell) + } else { + if row.showsDisclosureIndicator { + cell.accessoryType = isSplitViewDisplayed ? .none : .disclosureIndicator + } else { + cell.accessoryType = .none + } + WPStyleGuide.configureTableViewCell(cell) + } + + cell.textLabel?.text = row.title + cell.accessibilityIdentifier = row.accessibilityIdentifier + cell.detailTextLabel?.text = row.detail + cell.imageView?.image = row.image + cell.imageView?.tintColor = row.imageColor + + if let accessoryView = row.accessoryView { + cell.accessoryView = accessoryView + } + + return cell + } + + func configureSotWCell(tableView: UITableView) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CellIdentifiers.sotWCard + ) as? SotWTableViewCell else { + return UITableViewCell() + } + + cell.configure { [weak viewController] in + viewController?.configureTableViewData() + viewController?.reloadTableViewPreservingSelection() + } + + return cell + } + + func configureJetpackInstallCell(tableView: UITableView) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CellIdentifiers.jetpackInstall + ) as? JetpackRemoteInstallTableViewCell, + let viewController else { + return UITableViewCell() + } + + cell.configure(blog: blog, viewController: viewController) + return cell + } + + func configureMigrationSuccessCell(tableView: UITableView) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CellIdentifiers.migrationSuccess + ) as? MigrationSuccessCell, + let viewController else { + return UITableViewCell() + } + + if viewController.isSidebarModeEnabled { + cell.configureForSidebarMode() + } + cell.configure(with: viewController) + return cell + } + + func configureJetpackBrandingCell(tableView: UITableView) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CellIdentifiers.jetpackBrandingCard + ) as? JetpackBrandingMenuCardCell, + let viewController else { + return UITableViewCell() + } + + cell.configure(with: viewController) + return cell + } +} + +private extension BlogDetailsTableViewModel { + func buildHomeSection() -> Section { + return Section(rows: [Row.home(viewController: viewController)], category: .home) + } + + func buildContentSection() -> Section { + var rows: [Row] = [] + + rows.append(Row.posts(viewController: viewController)) + + if blog.supports(.pages) { + rows.append(Row.pages(viewController: viewController)) + } + + rows.append(Row.media(viewController: viewController)) + rows.append(Row.comments(viewController: viewController)) + + let title = isSplitViewDisplayed ? nil : BlogDetailsViewController.Strings.contentSectionTitle + return Section(title: title, rows: rows, category: .content) + } + + func buildRemoveSiteSection() -> Section { + return Section(rows: [Row.removeSite(viewController: viewController)], category: .removeSite) + } + + func buildJetpackSection() -> Section { + var rows: [Row] = [] + + if blog.isViewingStatsAllowed() { + rows.append(Row.stats(viewController: viewController)) + } + + if blog.supports(.activity) && !blog.isWPForTeams() { + rows.append(Row.activityLog(viewController: viewController)) + } + + if blog.isBackupsAllowed() { + rows.append(Row.backup(viewController: viewController)) + } + + if blog.isScanAllowed() { + rows.append(Row.scan(viewController: viewController)) + } + + if blog.supports(.jetpackSettings) { + rows.append(Row.jetpackSettings(viewController: viewController)) + } + + if viewController?.shouldShowBlaze() == true { + rows.append(Row.blaze(viewController: viewController)) + } + + let title = if blog.supports(.jetpackSettings) { + NSLocalizedString("Jetpack", comment: "Section title for the publish table section in the blog details screen") + } else { + "" + } + + return Section(title: title, rows: rows, category: .jetpack) + } + + func buildGeneralSection() -> Section { + var rows: [Row] = [] + + if blog.isViewingStatsAllowed() { + rows.append(Row.stats(viewController: viewController)) + } + + if blog.supports(.activity) && !blog.isWPForTeams() { + rows.append(Row.activity(viewController: viewController)) + } + + if viewController?.shouldShowBlaze() == true { + rows.append(Row.blaze(viewController: viewController)) + } + + return Section(rows: rows, category: .general) + } + + func buildPublishTypeSection() -> Section { + var rows: [Row] = [] + + rows.append(Row.posts(viewController: viewController)) + rows.append(Row.media(viewController: viewController)) + + if blog.supports(.pages) { + rows.append(Row.pages(viewController: viewController)) + } + + rows.append(Row.comments(viewController: viewController)) + + let title = NSLocalizedString("Publish", comment: "Section title for the publish table section in the blog details screen") + return Section(title: title, rows: rows, category: .content) + } + + func buildPersonalizeSection() -> Section { + var rows: [Row] = [] + + if blog.supports(.themeBrowsing) && !blog.isWPForTeams() { + rows.append(Row.themes(viewController: viewController)) + } + + if blog.supports(.menus) { + rows.append(Row.menus(viewController: viewController)) + } + + let title = NSLocalizedString("Personalize", comment: "Section title for the personalize table section in the blog details screen.") + return Section(title: title, rows: rows, category: .personalize) + } + + func buildConfigurationSection() -> Section { + guard let viewController else { + return Section(title: "Configure", rows: [], category: .configure) + } + + var rows: [Row] = [] + + // Me row + if viewController.shouldAddMeRow() { + rows.append(Row.me(icon: gravatarIcon, viewController: viewController)) + // Note: Gravatar image download would be handled by viewController + } + + // Sharing row + if viewController.shouldAddSharingRow() { + rows.append(Row.sharing(viewController: viewController)) + } + + // People row + if viewController.shouldAddPeopleRow() { + rows.append(Row.people(viewController: viewController)) + } + + // Users row + if viewController.shouldAddUsersRow() { + rows.append(Row.users(viewController: viewController)) + } + + // Plugins row + if viewController.shouldAddPluginsRow() { + rows.append(Row.plugins(viewController: viewController)) + } + + // Site Settings row (always included) + rows.append(Row.siteSettings(viewController: viewController)) + + // Domains row + if viewController.shouldAddDomainRegistrationRow() { + rows.append(Row.domains(viewController: viewController)) + } + + let title = NSLocalizedString("Configure", comment: "Section title for the configure table section in the blog details screen") + return Section(title: title, rows: rows, category: .configure) + } + + func buildExternalSection() -> Section { + guard let viewController else { + return Section(title: "External", rows: [], category: .external) + } + + var rows: [Row] = [] + + rows.append(Row.viewSite(viewController: viewController)) + + if shouldDisplayLinkToWPAdmin(for: blog) { + rows.append(Row.admin(viewController: viewController, blog: blog)) + } + + let title = NSLocalizedString("External", comment: "Section title for the external table section in the blog details screen") + return Section(title: title, rows: rows, category: .external) + } + + func buildTrafficSection() -> Section? { + guard let viewController else { return nil } + + var rows: [Row] = [] + + if blog.isViewingStatsAllowed() { + rows.append(Row.stats(viewController: viewController)) + } + + if viewController.shouldShowSubscribersRow { + rows.append(Row.subscribers(viewController: viewController)) + } + + if viewController.shouldAddSharingRow() { + rows.append(Row.social(viewController: viewController)) + } + + if viewController.shouldShowBlaze() { + rows.append(Row.blaze(viewController: viewController)) + } + + if rows.isEmpty { + return nil + } + + let title = BlogDetailsViewController.Strings.trafficSectionTitle + return Section(title: title, rows: rows, category: .traffic) + } + + func buildMaintenanceSections() -> [Section] { + guard let viewController else { return [] } + + var sections: [Section] = [] + var firstSectionRows: [Row] = [] + var secondSectionRows: [Row] = [] + var thirdSectionRows: [Row] = [] + + // First section: Activity, Backup, Scan, Site Monitoring + if blog.supports(.activity) && !blog.isWPForTeams() { + firstSectionRows.append(Row.activityLog(viewController: viewController)) + } + + if blog.isBackupsAllowed() { + firstSectionRows.append(Row.backup(viewController: viewController)) + } + + if blog.isScanAllowed() { + firstSectionRows.append(Row.scan(viewController: viewController)) + } + + if RemoteFeatureFlag.siteMonitoring.enabled() && blog.supports(.siteMonitoring) { + firstSectionRows.append(Row.siteMonitoring(viewController: viewController)) + } + + // Second section: People, Users, Plugins, Themes, Menus, Domains, Application Passwords, Site Settings + if viewController.shouldAddPeopleRow() { + secondSectionRows.append(Row.people(viewController: viewController)) + } + + if viewController.shouldAddUsersRow() { + secondSectionRows.append(Row.users(viewController: viewController)) + } + + if viewController.shouldAddPluginsRow() { + secondSectionRows.append(Row.plugins(viewController: viewController)) + } + + if blog.supports(.themeBrowsing) && !blog.isWPForTeams() { + secondSectionRows.append(Row.themes(viewController: viewController)) + } + + if blog.supports(.menus) { + secondSectionRows.append(Row.menus(viewController: viewController)) + } + + if viewController.shouldAddDomainRegistrationRow() { + secondSectionRows.append(Row.domains(viewController: viewController)) + } + + if FeatureFlag.allowApplicationPasswords.enabled { + secondSectionRows.append(Row.applicationPasswords(viewController: viewController)) + } + + // Site Settings (always included) + secondSectionRows.append(Row.siteSettings(viewController: viewController)) + + // Third section: WP Admin + if shouldDisplayLinkToWPAdmin(for: blog) { + thirdSectionRows.append(Row.admin(viewController: viewController, blog: blog)) + } + + // Build sections with proper titles + let sectionTitle = BlogDetailsViewController.Strings.maintenanceSectionTitle + var shouldAddSectionTitle = true + + if !firstSectionRows.isEmpty { + sections.append(Section( + title: sectionTitle, + rows: firstSectionRows, + category: .maintenance + )) + shouldAddSectionTitle = false + } + + if !secondSectionRows.isEmpty { + sections.append(Section( + title: shouldAddSectionTitle ? sectionTitle : nil, + rows: secondSectionRows, + category: .maintenance + )) + shouldAddSectionTitle = false + } + + if !thirdSectionRows.isEmpty { + sections.append(Section( + title: shouldAddSectionTitle ? sectionTitle : nil, + rows: thirdSectionRows, + category: .maintenance + )) + } + + return sections + } + + // MARK: - Helper Methods + + private func shouldDisplayLinkToWPAdmin(for blog: Blog) -> Bool { + if !blog.isHostedAtWPcom { + return true + } + // For .com users, check if account was created before HideWPAdminDate + let hideWPAdminDateString = "2015-09-07T00:00:00Z" + guard let hideWPAdminDate = ISO8601DateFormatter().date(from: hideWPAdminDateString) else { + return false + } + let context = ContextManager.shared.mainContext + guard let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context), + let dateCreated = defaultAccount.dateCreated else { + return false + } + return dateCreated < hideWPAdminDate + } +} + +enum BlogDetailsUserInfoKeys { + static let source = "source" + static let showPicker = "show-picker" + static let showManagePlugins = "show-manage-plugins" + static let siteMonitoringTab = "site-monitoring-tab" +} + +// MARK: - Table view content + +private enum SectionCategory { + case reminders + case domainCredit + case home + case general + case jetpack + case personalize + case configure + case external + case removeSite + case migrationSuccess + case jetpackBrandingCard + case jetpackInstallCard + case sotW2023Card + case content + case traffic + case maintenance +} + +enum BlogDetailsRowKind { + case reminders + case domain + case stats + case posts + case customize + case themes + case media + case pages + case activity + case backup + case scan + case jetpackSettings + case me + case comments + case sharing + case people + case subscribers + case plugins + case home + case migrationSuccess + case jetpackBrandingCard + case blaze + case menu + case applicationPasswords + case siteMonitoring + case viewSite + case admin + case siteSettings + case removeSite +} + +private struct Row { + let kind: BlogDetailsRowKind + let title: String + let accessibilityIdentifier: String? + let accessibilityHint: String? + let image: UIImage? + let imageColor: UIColor? + let accessoryView: UIView? + let detail: String? + let showsSelectionState: Bool + let showsDisclosureIndicator: Bool + let action: (([String: Any]) -> Void)? + + init( + kind: BlogDetailsRowKind, + title: String, + accessibilityIdentifier: String? = nil, + accessibilityHint: String? = nil, + image: UIImage?, + imageColor: UIColor? = .label, + accessoryView: UIView? = nil, + detail: String? = nil, + showsSelectionState: Bool = true, + showsDisclosureIndicator: Bool = true, + action: (([String: Any]) -> Void)? = nil, + ) { + self.title = title + self.accessibilityIdentifier = accessibilityIdentifier + self.accessibilityHint = accessibilityHint + self.image = imageColor == nil ? image : image?.withRenderingMode(.alwaysTemplate) + self.imageColor = imageColor + self.accessoryView = accessoryView + self.detail = detail + self.showsSelectionState = showsSelectionState + self.showsDisclosureIndicator = showsDisclosureIndicator + self.action = action + self.kind = kind + } +} + +extension Row { + static func home(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .home, + title: NSLocalizedString("Home", comment: "Noun. Links to a blog's dashboard screen."), + accessibilityIdentifier: "Home Row", + image: UIImage(named: "site-menu-home"), + action: { [weak viewController] _ in + viewController?.showDashboard() + } + ) + } + + static func posts(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .posts, + title: NSLocalizedString("Posts", comment: "Noun. Title. Links to the blog's Posts screen."), + accessibilityIdentifier: "Blog Post Row", + image: (UIImage(named: "site-menu-posts"))?.imageFlippedForRightToLeftLayoutDirection(), + action: { [weak viewController] userInfo in + // When called from showDetailView, use .link as source (matching Objective-C behavior) + // When called from direct tap, use .row (default behavior) + let source: BlogDetailsNavigationSource = userInfo.isEmpty ? .row : .link + viewController?.showPostList(from: source) + } + ) + } + + static func pages(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .pages, + title: NSLocalizedString("Pages", comment: "Noun. Title. Links to the blog's Pages screen."), + accessibilityIdentifier: "Site Pages Row", + image: UIImage(named: "site-menu-pages"), + action: { [weak viewController] userInfo in + // When called from showDetailView, use .link as source (matching Objective-C behavior) + // When called from direct tap, use .row (default behavior) + let source: BlogDetailsNavigationSource = userInfo.isEmpty ? .row : .link + viewController?.showPageList(from: source) + } + ) + } + + static func media(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .media, + title: NSLocalizedString("Media", comment: "Noun. Title. Links to the blog's Media library."), + accessibilityIdentifier: "Media Row", + image: UIImage(named: "site-menu-media"), + action: { [weak viewController] userInfo in + let showPicker = (userInfo[BlogDetailsUserInfoKeys.showPicker] as? NSNumber)?.boolValue ?? false + viewController?.showMediaLibrary(from: .link, showPicker: showPicker) + } + ) + } + + static func comments(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .comments, + title: NSLocalizedString("Comments", comment: "Noun. Title. Links to the blog's Comments screen."), + image: (UIImage(named: "site-menu-comments"))?.imageFlippedForRightToLeftLayoutDirection(), + action: { [weak viewController] userInfo in + // When called from showDetailView, use .link as source (matching Objective-C behavior) + // When called from direct tap, use .row (default behavior) + let source: BlogDetailsNavigationSource = userInfo.isEmpty ? .row : .link + viewController?.showComments(from: source) + } + ) + } + + static func removeSite(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .removeSite, + title: NSLocalizedString("Remove Site", comment: "Button to remove a site from the app"), + image: nil, + showsSelectionState: false, + action: { [weak viewController] _ in + viewController?.tableView.deselectSelectedRowWithAnimation(true) + viewController?.showRemoveSiteAlert() + } + ) + } + + static func stats(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .stats, + title: NSLocalizedString("Stats", comment: "Noun. Abbv. of Statistics. Links to a blog's Stats screen."), + accessibilityIdentifier: "Stats Row", + image: UIImage(named: "site-menu-stats"), + action: { [weak viewController] userInfo in + let sourceValue = userInfo[BlogDetailsUserInfoKeys.source] as? NSNumber + let source = sourceValue.map { BlogDetailsNavigationSource(rawValue: $0.intValue) ?? .link } ?? .link + viewController?.showStats(from: source) + } + ) + } + + static func activityLog(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .activity, + title: NSLocalizedString("Activity Log", comment: "Noun. Links to a blog's Activity screen."), + accessibilityIdentifier: "Activity Log Row", + image: UIImage(named: "site-menu-activity"), + action: { [weak viewController] _ in + viewController?.showActivity() + } + ) + } + + static func activity(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .activity, + title: NSLocalizedString("Activity", comment: "Noun. Links to a blog's Activity screen."), + image: UIImage(named: "site-menu-activity"), + action: { [weak viewController] _ in + viewController?.showActivity() + } + ) + } + + static func backup(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .backup, + title: NSLocalizedString("Backup", comment: "Noun. Links to a blog's Jetpack Backups screen."), + accessibilityIdentifier: "Backup Row", + image: UIImage.gridicon(.cloudOutline), + action: { [weak viewController] _ in + viewController?.showBackup() + } + ) + } + + static func scan(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .scan, + title: NSLocalizedString("Scan", comment: "Noun. Links to a blog's Jetpack Scan screen."), + accessibilityIdentifier: "Scan Row", + image: UIImage(named: "jetpack-scan-menu-icon"), + action: { [weak viewController] _ in + viewController?.showScan() + } + ) + } + + static func jetpackSettings(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .jetpackSettings, + title: NSLocalizedString("Jetpack Settings", comment: "Noun. Title. Links to the blog's Settings screen."), + accessibilityIdentifier: "Jetpack Settings Row", + image: UIImage(named: "site-menu-settings"), + action: { [weak viewController] _ in + viewController?.showJetpackSettings() + } + ) + } + + static func blaze(viewController: BlogDetailsViewController?) -> Row { + let iconSize = CGSize(width: 24.0, height: 24.0) + let blazeIcon = UIImage(named: "icon-blaze")?.resized(to: iconSize, format: .scaleAspectFit) + return Row( + kind: .blaze, + title: NSLocalizedString("Blaze", comment: "Noun. Links to a blog's Blaze screen."), + accessibilityIdentifier: "Blaze Row", + image: blazeIcon?.imageFlippedForRightToLeftLayoutDirection(), + imageColor: nil, + showsSelectionState: RemoteFeatureFlag.blazeManageCampaigns.enabled(), + action: { [weak viewController] _ in + viewController?.showBlaze() + } + ) + } + + static func themes(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .themes, + title: NSLocalizedString("Themes", comment: "Themes option in the blog details"), + image: UIImage(named: "site-menu-themes"), + action: { [weak viewController] _ in + viewController?.showThemes() + } + ) + } + + static func menus(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .menu, + title: NSLocalizedString("Menus", comment: "Menus option in the blog details"), + image: UIImage.gridicon(.menus).imageFlippedForRightToLeftLayoutDirection(), + action: { [weak viewController] _ in + viewController?.showMenus() + } + ) + } + + static func me(icon: UIImage?, viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .me, + title: NSLocalizedString("Me", comment: "Noun. Title. Links to the Me screen."), + image: icon ?? UIImage.gridicon(.userCircle), + action: { [weak viewController] _ in + viewController?.showMe() + } + ) + } + + static func sharing(viewController: BlogDetailsViewController?) -> Row { + let sharingTitle = AppConfiguration.isWordPress + ? NSLocalizedString("Sharing", comment: "Noun. Title. Links to a blog's sharing options.") + : BlogDetailsViewController.Strings.socialRowTitle + return Row( + kind: .sharing, + title: sharingTitle, + image: UIImage(named: "site-menu-social"), + action: { [weak viewController] userInfo in + // When called from showDetailView, use .link as source (matching Objective-C behavior) + // When called from direct tap, use .row (default behavior) + let source: BlogDetailsNavigationSource = userInfo.isEmpty ? .row : .link + viewController?.showSharing(from: source) + } + ) + } + + static func people(viewController: BlogDetailsViewController?) -> Row { + let title = viewController?.shouldShowSubscribersRow == true + ? BlogDetailsViewController.Strings.users + : NSLocalizedString("People", comment: "Noun. Title. Links to the people management feature.") + return Row( + kind: .people, + title: title, + accessibilityIdentifier: "Users Row", + image: UIImage(named: "site-menu-people"), + action: { [weak viewController] _ in + viewController?.showPeople() + } + ) + } + + static func subscribers(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .subscribers, + title: BlogDetailsViewController.Strings.subscribers, + image: UIImage(named: "wpl-mail"), + action: { [weak viewController] _ in + MainActor.assumeIsolated { + guard let viewController else { return } + guard let blog = SubscribersBlog(blog: viewController.blog) else { + return wpAssertionFailure("incompatible blog") + } + let vc = SubscribersViewController(blog: blog) + viewController.presentationDelegate?.presentBlogDetailsViewController(vc) + } + } + ) + } + + static func users(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .people, + title: NSLocalizedString("Users", comment: "Noun. Title. Links to the user management feature."), + accessibilityIdentifier: "Users Row", + image: UIImage(named: "site-menu-people"), + action: { [weak viewController] _ in + viewController?.showUsers() + } + ) + } + + static func plugins(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .plugins, + title: NSLocalizedString("Plugins", comment: "Noun. Title. Links to the plugin management feature."), + image: UIImage(named: "site-menu-plugins"), + action: { [weak viewController] userInfo in + let showManagement = (userInfo[BlogDetailsUserInfoKeys.showManagePlugins] as? NSNumber)?.boolValue ?? false + if showManagement { + viewController?.showManagePluginsScreen() + } else { + viewController?.showPlugins() + } + } + ) + } + + static func siteSettings(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .siteSettings, + title: NSLocalizedString("Site Settings", comment: "Noun. Title. Links to the blog's Settings screen."), + accessibilityIdentifier: "Settings Row", + image: UIImage(named: "site-menu-settings"), + action: { [weak viewController] _ in + viewController?.showSettings(from: .row) + } + ) + } + + static func domains(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .domain, + title: NSLocalizedString("Domains", comment: "Noun. Title. Links to the Domains screen."), + accessibilityIdentifier: "Domains Row", + image: UIImage(named: "site-menu-domains"), + action: { [weak viewController] _ in + viewController?.showDomains(from: .row) + } + ) + } + + static func viewSite(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .viewSite, + title: NSLocalizedString("View Site", comment: "Action title. Opens the user's site in an in-app browser"), + image: UIImage.gridicon(.globe), + showsSelectionState: false, + action: { [weak viewController] _ in + viewController?.showViewSite(from: .row) + } + ) + } + + static func admin(viewController: BlogDetailsViewController?, blog: Blog) -> Row { + let adminTitle: String + if blog.isHostedAtWPcom { + adminTitle = NSLocalizedString("Dashboard", comment: "Action title. Noun. Opens the user's WordPress.com dashboard in an external browser.") + } else { + adminTitle = NSLocalizedString("WP Admin", comment: "Action title. Noun. Opens the user's WordPress Admin in an external browser.") + } + + let iconSize = CGSize(width: 17.0, height: 17.0) + let accessoryImage = UIImage.gridicon(.external, size: iconSize).imageFlippedForRightToLeftLayoutDirection() + let accessoryView = UIImageView(image: accessoryImage) + accessoryView.tintColor = WPStyleGuide.cellGridiconAccessoryColor() + + return Row( + kind: .admin, + title: adminTitle, + image: UIImage.gridicon(.mySites), + accessoryView: accessoryView, + showsSelectionState: false, + action: { [weak viewController] _ in + viewController?.showViewAdmin() + viewController?.tableView.deselectSelectedRowWithAnimation(true) + } + ) + } + + static func social(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .sharing, + title: BlogDetailsViewController.Strings.socialRowTitle, + image: UIImage(named: "site-menu-social"), + action: { [weak viewController] userInfo in + // When called from showDetailView, use .link as source (matching Objective-C behavior) + // When called from direct tap, use .row (default behavior) + let source: BlogDetailsNavigationSource = userInfo.isEmpty ? .row : .link + viewController?.showSharing(from: source) + } + ) + } + + static func siteMonitoring(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .siteMonitoring, + title: BlogDetailsViewController.Strings.siteMonitoringRowTitle, + accessibilityIdentifier: "Site Monitoring Row", + image: UIImage(named: "tool"), + action: { [weak viewController] userInfo in + let selectedTab = userInfo[BlogDetailsUserInfoKeys.siteMonitoringTab] as? NSNumber + viewController?.showSiteMonitoring(selectedTab: selectedTab) + } + ) + } + + static func applicationPasswords(viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .applicationPasswords, + title: NSLocalizedString("Application Passwords", comment: "Link to Application Passwords section"), + accessibilityIdentifier: "Application Passwords Row", + image: UIImage(systemName: "key"), + action: { [weak viewController] _ in + viewController?.showApplicationPasswords() + } + ) + } +} + +private enum CellIdentifiers { + static let standard = "BlogDetailsCell" + static let plan = "BlogDetailsPlanCell" + static let settings = "BlogDetailsSettingsCell" + static let removeSite = "BlogDetailsRemoveSiteCell" + static let sectionFooter = "BlogDetailsSectionFooterView" + static let sectionFooterIdentifier = "BlogDetailsSectionFooterIdentifier" + static let migrationSuccess = "BlogDetailsMigrationSuccessCellIdentifier" + static let jetpackBrandingCard = "BlogDetailsJetpackBrandingCardCellIdentifier" + static let jetpackInstall = "BlogDetailsJetpackInstallCardCellIdentifier" + static let sotWCard = "BlogDetailsSotWCardCellIdentifier" +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift index 6c58ba656488..0f025154e466 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Me.swift @@ -5,7 +5,7 @@ import Gravatar extension BlogDetailsViewController { - @objc public func downloadGravatarImage(for row: BlogDetailsRow, forceRefresh: Bool = false) { + @objc public func downloadGravatarImage(forceRefresh: Bool = false) { guard let email = blog.account?.email else { return } @@ -16,8 +16,7 @@ extension BlogDetailsViewController { return } - row.image = gravatarIcon - self?.reloadMeRow() + self?.tableViewModel.gravatarIcon = gravatarIcon } } @@ -27,10 +26,9 @@ extension BlogDetailsViewController { } @objc private func refreshAvatar(_ notification: Foundation.Notification) { - guard let meRow, - let email = blog.account?.email, + guard let email = blog.account?.email, notification.userInfoHasEmail(email) else { return } - downloadGravatarImage(for: meRow, forceRefresh: true) + downloadGravatarImage(forceRefresh: true) } @objc private func updateGravatarImage(_ notification: Foundation.Notification) { @@ -43,13 +41,7 @@ extension BlogDetailsViewController { } ImageCache.shared.setImage(image, forKey: url.absoluteString) - meRow?.image = gravatarIcon - reloadMeRow() - } - - private func reloadMeRow() { - let meIndexPath = indexPath(for: .me) - tableView.reloadRows(at: [meIndexPath], with: .automatic) + tableViewModel.gravatarIcon = gravatarIcon } private enum Metrics { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift index a93893cc5f56..09c3642bdfc6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift @@ -6,56 +6,7 @@ import WordPressUI import WordPressAPI import WordPressCore -extension Array where Element: BlogDetailsSection { - fileprivate func findSectionIndex(of category: BlogDetailsSectionCategory) -> Int? { - return firstIndex(where: { $0.category == category }) - } -} - -extension BlogDetailsSubsection { - func sectionCategory(for blog: Blog) -> BlogDetailsSectionCategory { - switch self { - case .domainCredit: - return .domainCredit - case .activity, .jetpackSettings, .siteMonitoring: - return .jetpack - case .stats where blog.shouldShowJetpackSection: - return .jetpack - case .stats where !blog.shouldShowJetpackSection: - return .general - case .pages, .posts, .media, .comments: - return .content - case .themes, .customize: - return .personalize - case .me, .sharing, .people, .plugins: - return .configure - case .home: - return .home - default: - fatalError() - } - } -} - extension BlogDetailsViewController { - @objc public func findSectionIndex(sections: [BlogDetailsSection], category: BlogDetailsSectionCategory) -> Int { - return sections.findSectionIndex(of: category) ?? NSNotFound - } - - @objc public func sectionCategory(subsection: BlogDetailsSubsection, blog: Blog) -> BlogDetailsSectionCategory { - return subsection.sectionCategory(for: blog) - } - - @objc public func defaultSubsection() -> BlogDetailsSubsection { - if !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { - return .posts - } - if isDashboardEnabled() { - return .home - } - return .stats - } - @objc public func shouldAddJetpackSection() -> Bool { guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { return false diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift index a8e2b14d58fb..268b6f43c117 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift @@ -3,37 +3,44 @@ import Foundation @objc(BlogDetailsViewControllerStrings) public class objc_BlogDetailsViewController_Strings: NSObject { - @objc public class func contentSectionTitle() -> String { Strings.contentSectionTitle } - @objc public class func trafficSectionTitle() -> String { Strings.trafficSectionTitle } - @objc public class func maintenanceSectionTitle() -> String { Strings.maintenanceSectionTitle } - @objc public class func socialRowTitle() -> String { Strings.socialRowTitle } - @objc public class func siteMonitoringRowTitle() -> String { Strings.siteMonitoringRowTitle } + @objc public class func contentSectionTitle() -> String { BlogDetailsViewController.Strings.contentSectionTitle } + @objc public class func trafficSectionTitle() -> String { BlogDetailsViewController.Strings.trafficSectionTitle } + @objc public class func maintenanceSectionTitle() -> String { BlogDetailsViewController.Strings.maintenanceSectionTitle } + @objc public class func socialRowTitle() -> String { BlogDetailsViewController.Strings.socialRowTitle } + @objc public class func siteMonitoringRowTitle() -> String { BlogDetailsViewController.Strings.siteMonitoringRowTitle } } -private enum Strings { - static let contentSectionTitle = NSLocalizedString( - "my-site.menu.content.section.title", - value: "Content", - comment: "Section title for the content table section in the blog details screen" - ) - static let trafficSectionTitle = NSLocalizedString( - "my-site.menu.traffic.section.title", - value: "Traffic", - comment: "Section title for the traffic table section in the blog details screen" - ) - static let maintenanceSectionTitle = NSLocalizedString( - "my-site.menu.maintenance.section.title", - value: "Maintenance", - comment: "Section title for the maintenance table section in the blog details screen" - ) - static let socialRowTitle = NSLocalizedString( - "my-site.menu.social.row.title", - value: "Social", - comment: "Title for the social row in the blog details screen" - ) - static let siteMonitoringRowTitle = NSLocalizedString( - "my-site.menu.site-monitoring.row.title", - value: "Site Monitoring", - comment: "Title for the site monitoring row in the blog details screen" - ) +extension BlogDetailsViewController { + + enum Strings { + static let contentSectionTitle = NSLocalizedString( + "my-site.menu.content.section.title", + value: "Content", + comment: "Section title for the content table section in the blog details screen" + ) + static let trafficSectionTitle = NSLocalizedString( + "my-site.menu.traffic.section.title", + value: "Traffic", + comment: "Section title for the traffic table section in the blog details screen" + ) + static let maintenanceSectionTitle = NSLocalizedString( + "my-site.menu.maintenance.section.title", + value: "Maintenance", + comment: "Section title for the maintenance table section in the blog details screen" + ) + static let socialRowTitle = NSLocalizedString( + "my-site.menu.social.row.title", + value: "Social", + comment: "Title for the social row in the blog details screen" + ) + static let siteMonitoringRowTitle = NSLocalizedString( + "my-site.menu.site-monitoring.row.title", + value: "Site Monitoring", + comment: "Title for the site monitoring row in the blog details screen" + ) + + static let users = NSLocalizedString("mySite.menu.users", value: "Users", comment: "Title for the menu item") + static let subscribers = NSLocalizedString("mySite.menu.subscribers", value: "Subscribers", comment: "Title for the menu item") + } + } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 0e6222631b08..602971450704 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -13,28 +13,6 @@ extension BlogDetailsViewController { blog.supports(.people) } - @objc public func makeSubscribersRow() -> BlogDetailsRow { - BlogDetailsRow(title: Strings.subscribers, image: UIImage(named: "wpl-mail") ?? UIImage()) { [weak self] in - guard let self else { return } - guard let blog = SubscribersBlog(blog: self.blog) else { - return wpAssertionFailure("incompatible blog") - } - let vc = SubscribersViewController(blog: blog) - self.presentationDelegate?.presentBlogDetailsViewController(vc) - } - } - - @objc public func makePeopleRow() -> BlogDetailsRow { - let row = BlogDetailsRow( - title: shouldShowSubscribersRow ? Strings.users : NSLocalizedString("People", comment: "Noun. Title. Links to the people management feature."), - image: UIImage(named: "site-menu-people") ?? UIImage() - ) { [weak self] in - self?.showPeople() - } - row.accessibilityIdentifier = "Users Row" - return row - } - @objc public func isDashboardEnabled() -> Bool { return JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() && blog.isAccessibleThroughWPCom() } @@ -69,6 +47,14 @@ extension BlogDetailsViewController { // MARK: - BlogDetailsViewController (Navigation) extension BlogDetailsViewController { + func showDetailView(for row: BlogDetailsRowKind, userInfo: [String: Any] = [:]) { + self.tableViewModel.showDetailView(for: row, userInfo: userInfo) + } + + func showDetailViewForMe(userInfo: [String: Any]) -> MeViewController { + self.tableViewModel.showDetailViewForMe(userInfo: userInfo) + } + @objc public func showDashboard() { if isSidebarModeEnabled { let controller = MySiteViewController.make(forBlog: blog, isSidebarModeEnabled: true) @@ -402,11 +388,6 @@ private enum Constants { static let calypsoDashboardPath = "https://wordpress.com/home/" } -private enum Strings { - static let users = NSLocalizedString("mySite.menu.users", value: "Users", comment: "Title for the menu item") - static let subscribers = NSLocalizedString("mySite.menu.subscribers", value: "Subscribers", comment: "Title for the menu item") -} - // Necessary data that's required to get an application application from a given site. @objc public class ApplicationPasswordAuthenticationInfo: NSObject { public let siteAddress: String diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h index e6deeee201e5..523d1c2ac824 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h @@ -5,123 +5,22 @@ @class IntrinsicTableView; @class MeViewController; @class ApplicationPasswordAuthenticationInfo; +@class BlogDetailsTableViewModel; @protocol BlogDetailHeader; -typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { - BlogDetailsSectionCategoryReminders, - BlogDetailsSectionCategoryDomainCredit, - BlogDetailsSectionCategoryHome, - BlogDetailsSectionCategoryGeneral, - BlogDetailsSectionCategoryJetpack, - BlogDetailsSectionCategoryPersonalize, - BlogDetailsSectionCategoryConfigure, - BlogDetailsSectionCategoryExternal, - BlogDetailsSectionCategoryRemoveSite, - BlogDetailsSectionCategoryMigrationSuccess, - BlogDetailsSectionCategoryJetpackBrandingCard, - BlogDetailsSectionCategoryJetpackInstallCard, - BlogDetailsSectionCategorySotW2023Card, - BlogDetailsSectionCategoryContent, - BlogDetailsSectionCategoryTraffic, - BlogDetailsSectionCategoryMaintenance, -}; - -typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { - BlogDetailsSubsectionReminders, - BlogDetailsSubsectionDomainCredit, - BlogDetailsSubsectionStats, - BlogDetailsSubsectionPosts, - BlogDetailsSubsectionCustomize, - BlogDetailsSubsectionThemes, - BlogDetailsSubsectionMedia, - BlogDetailsSubsectionPages, - BlogDetailsSubsectionActivity, - BlogDetailsSubsectionJetpackSettings, - BlogDetailsSubsectionMe, - BlogDetailsSubsectionComments, - BlogDetailsSubsectionSharing, - BlogDetailsSubsectionPeople, - BlogDetailsSubsectionPlugins, - BlogDetailsSubsectionHome, - BlogDetailsSubsectionMigrationSuccess, - BlogDetailsSubsectionJetpackBrandingCard, - BlogDetailsSubsectionBlaze, - BlogDetailsSubsectionSiteMonitoring -}; - -@interface BlogDetailsSection : NSObject - -@property (nonatomic, strong, nullable, readonly) NSString *title; -@property (nonatomic, strong, nonnull, readonly) NSArray *rows; -@property (nonatomic, strong, nullable, readonly) NSString *footerTitle; -@property (nonatomic, readonly) BlogDetailsSectionCategory category; - -- (instancetype _Nonnull)initWithTitle:(NSString * __nullable)title andRows:(NSArray * __nonnull)rows category:(BlogDetailsSectionCategory)category; -- (instancetype _Nonnull)initWithTitle:(NSString * __nullable)title rows:(NSArray * __nonnull)rows footerTitle:(NSString * __nullable)footerTitle category:(BlogDetailsSectionCategory)category; - -@end - - -@interface BlogDetailsRow : NSObject - -@property (nonatomic, strong, nonnull) NSString *title; -@property (nonatomic, strong, nonnull) NSString *identifier; -@property (nonatomic, strong, nullable) NSString *accessibilityIdentifier; -@property (nonatomic, strong, nullable) NSString *accessibilityHint; -@property (nonatomic, strong, nonnull) UIImage *image; -@property (nonatomic, strong, nullable) UIColor *imageColor; -@property (nonatomic, strong, nullable) UIView *accessoryView; -@property (nonatomic, strong, nullable) NSString *detail; -@property (nonatomic) BOOL showsSelectionState; -@property (nonatomic) BOOL forDestructiveAction; -@property (nonatomic) BOOL showsDisclosureIndicator; -@property (nonatomic, copy, nullable) void (^callback)(void); - -- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - image:(UIImage * __nonnull)image - callback:(void(^_Nullable)(void))callback; - -- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - callback:(void(^_Nullable)(void))callback; - -- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - accessibilityHint:(NSString *__nullable)accessibilityHint - image:(UIImage * __nonnull)image - callback:(void(^_Nullable)(void))callback; - -- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - imageColor:(UIColor * __nullable)imageColor - callback:(void(^_Nullable)(void))callback; - -- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - imageColor:(UIColor * __nullable)imageColor - renderingMode:(UIImageRenderingMode)renderingMode - callback:(void(^_Nullable)(void))callback; - -@end - @protocol ScenePresenter; @protocol BlogDetailsPresentationDelegate - (void)presentBlogDetailsViewController:(UIViewController * __nonnull)viewController; @end -@interface BlogDetailsViewController : UIViewController +@interface BlogDetailsViewController : UIViewController @property (nonatomic, strong, nonnull) Blog * blog; @property (nonatomic, strong, readwrite) UITableView * _Nonnull tableView; +@property (nonatomic, strong, readonly) BlogDetailsTableViewModel *tableViewModel; @property (nonatomic) BOOL isScrollEnabled; @property (nonatomic, weak, nullable) id presentationDelegate; -@property (nonatomic, strong, nullable) BlogDetailsRow *meRow; /// A new display mode for the displaying it as part of the site menu. @property (nonatomic) BOOL isSidebarModeEnabled; @@ -129,23 +28,15 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { @property (nonatomic, weak) UIViewController *presentedSiteSettingsViewController; - (id _Nonnull)init; -- (void)showDetailViewForSubsection:(BlogDetailsSubsection)section; -- (void)showDetailViewForSubsection:(BlogDetailsSubsection)section userInfo:(nonnull NSDictionary *)userInfo; -- (NSIndexPath * _Nonnull)indexPathForSubsection:(BlogDetailsSubsection)subsection; - (void)reloadTableViewPreservingSelection; - (void)configureTableViewData; -- (nonnull MeViewController *)showDetailViewForMeSubsectionWithUserInfo:(nonnull NSDictionary *)userInfo; - - (void)switchToBlog:(nonnull Blog *)blog; - (void)showInitialDetailsForBlog; - (void)updateTableView:(nullable void(^)(void))completion; - (void)preloadMetadata; - (void)pulledToRefreshWith:(nonnull UIRefreshControl *)refreshControl onCompletion:(nullable void(^)(void))completion; -+ (nonnull NSString *)userInfoShowPickerKey; -+ (nonnull NSString *)userInfoSiteMonitoringTabKey; -+ (nonnull NSString *)userInfoShowManagemenetScreenKey; -+ (nonnull NSString *)userInfoSourceKey; +- (void)showRemoveSiteAlert; @end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index ef6d372204f4..184eb6129117 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -15,240 +15,20 @@ @import WordPressData; @import WordPressShared; -static NSString *const BlogDetailsCellIdentifier = @"BlogDetailsCell"; -static NSString *const BlogDetailsPlanCellIdentifier = @"BlogDetailsPlanCell"; -static NSString *const BlogDetailsSettingsCellIdentifier = @"BlogDetailsSettingsCell"; -static NSString *const BlogDetailsRemoveSiteCellIdentifier = @"BlogDetailsRemoveSiteCell"; -static NSString *const BlogDetailsSectionFooterIdentifier = @"BlogDetailsSectionFooterView"; -static NSString *const BlogDetailsMigrationSuccessCellIdentifier = @"BlogDetailsMigrationSuccessCell"; -static NSString *const BlogDetailsJetpackBrandingCardCellIdentifier = @"BlogDetailsJetpackBrandingCardCellIdentifier"; -static NSString *const BlogDetailsJetpackInstallCardCellIdentifier = @"BlogDetailsJetpackInstallCardCellIdentifier"; -static NSString *const BlogDetailsSotWCardCellIdentifier = @"BlogDetailsSotWCardCellIdentifier"; - -CGFloat const BlogDetailGridiconSize = 24.0; -CGFloat const BlogDetailGridiconAccessorySize = 17.0; -CGFloat const BlogDetailSectionTitleHeaderHeight = 40.0; -CGFloat const BlogDetailSectionsSpacing = 20.0; -CGFloat const BlogDetailSectionFooterHeight = 40.0; -NSTimeInterval const PreloadingCacheTimeout = 60.0 * 5; // 5 minutes -NSString * const HideWPAdminDate = @"2015-09-07T00:00:00Z"; - -CGFloat const BlogDetailReminderSectionHeaderHeight = 8.0; -CGFloat const BlogDetailReminderSectionFooterHeight = 1.0; - -#pragma mark - Helper Classes for Blog Details view model. - -@implementation NSMutableArray (NullableObjects) - -- (void)addNullableObject:(nullable id)anObject { - if (anObject != nil) { - [self addObject:anObject]; - } -} - -@end - -@implementation BlogDetailsRow - -- (instancetype)initWithTitle:(NSString * __nonnull)title - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - image:image - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:identifier - accessibilityIdentifier:nil - image:image - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - accessibilityIdentifier:accessibilityIdentifier - image:image - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:identifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:nil - image:image - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - accessibilityHint:(NSString *__nullable)accessibilityHint - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:identifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:accessibilityHint - image:image - imageColor:[UIColor labelColor] - renderingMode:UIImageRenderingModeAlwaysTemplate - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - accessibilityHint:(NSString *__nullable)accessibilityHint - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:accessibilityHint - image:image - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString *)title - accessibilityIdentifier:(NSString *)accessibilityIdentifier - image:(UIImage *)image - imageColor:(UIColor *)imageColor - callback:(void (^)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:nil - image:image - imageColor:imageColor - renderingMode:UIImageRenderingModeAlwaysTemplate - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString *)title - accessibilityIdentifier:(NSString *)accessibilityIdentifier - image:(UIImage *)image - imageColor:(UIColor *)imageColor - renderingMode:(UIImageRenderingMode)renderingMode - callback:(void (^)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:nil - image:image - imageColor:imageColor - renderingMode:renderingMode - callback:callback]; -} - - -- (instancetype)initWithTitle:(NSString * __nonnull)title - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - accessibilityHint:(NSString * __nullable)accessibilityHint - image:(UIImage * __nonnull)image - imageColor:(UIColor * __nullable)imageColor - callback:(void(^_Nullable)(void))callback -{ - return [self initWithTitle:title - identifier:BlogDetailsCellIdentifier - accessibilityIdentifier:accessibilityIdentifier - accessibilityHint:nil - image:image - imageColor:imageColor - renderingMode:UIImageRenderingModeAlwaysTemplate - callback:callback]; -} - -- (instancetype)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - accessibilityHint:(NSString *__nullable)accessibilityHint - image:(UIImage * __nonnull)image - imageColor:(UIColor * __nullable)imageColor - renderingMode:(UIImageRenderingMode)renderingMode - callback:(void(^)(void))callback -{ - self = [super init]; - if (self) { - _title = title; - _image = [image imageWithRenderingMode:renderingMode]; - _imageColor = imageColor; - _callback = callback; - _identifier = identifier; - _accessibilityIdentifier = accessibilityIdentifier; - _accessibilityHint = accessibilityHint; - _showsSelectionState = YES; - _showsDisclosureIndicator = YES; - } - return self; -} - -@end - -@implementation BlogDetailsSection -- (instancetype)initWithTitle:(NSString *)title - andRows:(NSArray *)rows - category:(BlogDetailsSectionCategory)category -{ - return [self initWithTitle:title rows:rows footerTitle:nil category:category]; -} - -- (instancetype)initWithTitle:(NSString *)title - rows:(NSArray *)rows - footerTitle:(NSString *)footerTitle - category:(BlogDetailsSectionCategory)category -{ - self = [super init]; - if (self) { - _title = title; - _rows = rows; - _footerTitle = footerTitle; - _category = category; - } - return self; -} -@end - #pragma mark - @interface BlogDetailsViewController () @property (nonatomic, strong) NSArray *headerViewHorizontalConstraints; -@property (nonatomic, strong) NSArray *tableSections; @property (nonatomic, strong) BlogService *blogService; -/// Used to restore the tableview selection during state restoration, and -/// also when switching between a collapsed and expanded split view controller presentation -@property (nonatomic, strong) NSIndexPath *restorableSelectedIndexPath; -@property (nonatomic) BlogDetailsSectionCategory selectedSectionCategory; - @property (nonatomic) BOOL hasLoggedDomainCreditPromptShownEvent; +@property (nonatomic, strong) BlogDetailsTableViewModel *tableViewModel; + @end @implementation BlogDetailsViewController -@synthesize restorableSelectedIndexPath = _restorableSelectedIndexPath; #pragma mark = Lifecycle Methods @@ -275,8 +55,10 @@ - (void)viewDidLoad _tableView = [[IntrinsicTableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; self.tableView.scrollEnabled = false; } - self.tableView.delegate = self; - self.tableView.dataSource = self; + + self.tableViewModel = [[BlogDetailsTableViewModel alloc] initWithBlog:self.blog viewController:self]; + [self.tableViewModel configureWithTableView:self.tableView]; + self.tableView.translatesAutoresizingMaskIntoConstraints = false; if (self.isSidebarModeEnabled) { self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; @@ -294,16 +76,6 @@ - (void)viewDidLoad [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; [WPStyleGuide configureAutomaticHeightRowsFor:self.tableView]; - [self.tableView registerClass:[WPTableViewCell class] forCellReuseIdentifier:BlogDetailsCellIdentifier]; - [self.tableView registerClass:[WPTableViewCellValue1 class] forCellReuseIdentifier:BlogDetailsPlanCellIdentifier]; - [self.tableView registerClass:[WPTableViewCellValue1 class] forCellReuseIdentifier:BlogDetailsSettingsCellIdentifier]; - [self.tableView registerClass:[WPTableViewCell class] forCellReuseIdentifier:BlogDetailsRemoveSiteCellIdentifier]; - [self.tableView registerClass:[BlogDetailsSectionFooterView class] forHeaderFooterViewReuseIdentifier:BlogDetailsSectionFooterIdentifier]; - [self.tableView registerClass:[MigrationSuccessCell class] forCellReuseIdentifier:BlogDetailsMigrationSuccessCellIdentifier]; - [self.tableView registerClass:[JetpackBrandingMenuCardCell class] forCellReuseIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; - [self.tableView registerClass:[JetpackRemoteInstallTableViewCell class] forCellReuseIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; - [self.tableView registerClass:[SotWTableViewCell class] forCellReuseIdentifier:BlogDetailsSotWCardCellIdentifier]; - self.tableView.cellLayoutMarginsFollowReadableWidth = YES; self.hasLoggedDomainCreditPromptShownEvent = NO; @@ -330,9 +102,7 @@ - (void)viewWillAppear:(BOOL)animated [self observeWillEnterForegroundNotification]; - if (!self.isSplitViewDisplayed) { - self.restorableSelectedIndexPath = nil; - } + [self.tableViewModel viewWillAppear]; // Configure and reload table data when appearing to ensure pending comment count is updated [self configureTableViewData]; @@ -364,11 +134,6 @@ - (void)viewWillDisappear:(BOOL)animated [self stopObservingWillEnterForegroundNotification]; } -- (void)viewDidDisappear:(BOOL)animated -{ - [super viewDidDisappear:animated]; -} - - (void)handleTraitChanges { // Required to add / remove "Home" section when switching between regular and compact width @@ -378,577 +143,11 @@ - (void)handleTraitChanges [self reloadTableViewPreservingSelection]; } -- (void)showDetailViewForSubsection:(BlogDetailsSubsection)section -{ - [self showDetailViewForSubsection:section userInfo:@{}]; -} - -- (void)showDetailViewForSubsection:(BlogDetailsSubsection)section userInfo:(NSDictionary *)userInfo -{ - NSIndexPath *indexPath = [self indexPathForSubsection:section]; - - switch (section) { - case BlogDetailsSubsectionReminders: - case BlogDetailsSubsectionDomainCredit: - case BlogDetailsSubsectionHome: - case BlogDetailsSubsectionMigrationSuccess: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showDashboard]; - break; - case BlogDetailsSubsectionJetpackBrandingCard: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - break; - case BlogDetailsSubsectionStats: { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - NSNumber *sourceValue = userInfo[[BlogDetailsViewController userInfoSourceKey]]; - BlogDetailsNavigationSource source = sourceValue ? sourceValue.unsignedIntegerValue : BlogDetailsNavigationSourceLink; - [self showStatsFromSource:source]; - break; - } - case BlogDetailsSubsectionPosts: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showPostListFromSource:BlogDetailsNavigationSourceLink]; - break; - case BlogDetailsSubsectionThemes: - case BlogDetailsSubsectionCustomize: - if ([self.blog supports:BlogFeatureThemeBrowsing] || [self.blog supports:BlogFeatureMenus]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showThemes]; - } - break; - case BlogDetailsSubsectionMedia: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - BOOL showPicker = userInfo[[BlogDetailsViewController userInfoShowPickerKey]] ?: NO; - [self showMediaLibraryFromSource:BlogDetailsNavigationSourceLink showPicker: showPicker]; - break; - case BlogDetailsSubsectionPages: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showPageListFromSource:BlogDetailsNavigationSourceLink]; - break; - case BlogDetailsSubsectionActivity: - if ([self.blog supports:BlogFeatureActivity]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showActivity]; - } - break; - case BlogDetailsSubsectionBlaze: - if ([self shouldShowBlaze]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showBlaze]; - } - break; - case BlogDetailsSubsectionJetpackSettings: - if ([self.blog supports:BlogFeatureActivity]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showJetpackSettings]; - } - break; - case BlogDetailsSubsectionComments: - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showCommentsFromSource:BlogDetailsNavigationSourceLink]; - break; - case BlogDetailsSubsectionMe: - [self showDetailViewForMeSubsectionWithUserInfo: userInfo]; - break; - case BlogDetailsSubsectionSharing: - if ([self.blog supports:BlogFeatureSharing]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showSharingFromSource:BlogDetailsNavigationSourceLink]; - } - break; - case BlogDetailsSubsectionPeople: - if ([self.blog supports:BlogFeaturePeople]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showPeople]; - } else if ([self.blog selfHostedSiteRestApi]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showUsers]; - } - break; - case BlogDetailsSubsectionPlugins: - if ([self.blog supports:BlogFeaturePluginManagement]) { - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - BOOL showManagemnet = userInfo[[BlogDetailsViewController userInfoShowManagemenetScreenKey]] ?: NO; - if (showManagemnet) { - [self showManagePluginsScreen]; - } else { - [self showPlugins]; - } - } - break; - case BlogDetailsSubsectionSiteMonitoring: - if ([RemoteFeature enabled:RemoteFeatureFlagSiteMonitoring] && [self.blog supports:BlogFeatureSiteMonitoring]) { - NSNumber *selectedTab = userInfo[[BlogDetailsViewController userInfoSiteMonitoringTabKey]]; - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showSiteMonitoringWithSelectedTab:selectedTab]; - } - break; - - } -} - -- (MeViewController *)showDetailViewForMeSubsectionWithUserInfo:(NSDictionary *)userInfo { - NSIndexPath *indexPath = [self indexPathForSubsection:BlogDetailsSubsectionMe]; - self.restorableSelectedIndexPath = indexPath; - [self.tableView selectRowAtIndexPath:indexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - return [self showMe]; -} - -// MARK: Todo: this needs to adjust based on the existence of the QSv2 section -- (NSIndexPath *)indexPathForSubsection:(BlogDetailsSubsection)subsection -{ - BlogDetailsSectionCategory sectionCategory = [self sectionCategoryWithSubsection:subsection blog: self.blog]; - NSInteger section = [self findSectionIndexWithSections:self.tableSections category:sectionCategory]; - switch (subsection) { - case BlogDetailsSubsectionReminders: - case BlogDetailsSubsectionHome: - case BlogDetailsSubsectionMigrationSuccess: - case BlogDetailsSubsectionJetpackBrandingCard: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionDomainCredit: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionStats: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionActivity: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionSiteMonitoring: - return [NSIndexPath indexPathForRow:2 inSection:section]; - case BlogDetailsSubsectionBlaze: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionJetpackSettings: - return [NSIndexPath indexPathForRow:1 inSection:section]; - case BlogDetailsSubsectionPosts: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionThemes: - case BlogDetailsSubsectionCustomize: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionMedia: - return [NSIndexPath indexPathForRow:2 inSection:section]; - case BlogDetailsSubsectionPages: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionComments: - return [NSIndexPath indexPathForRow:3 inSection:section]; - case BlogDetailsSubsectionMe: - case BlogDetailsSubsectionSharing: - return [NSIndexPath indexPathForRow:0 inSection:section]; - case BlogDetailsSubsectionPeople: - return [NSIndexPath indexPathForRow:1 inSection:section]; - case BlogDetailsSubsectionPlugins: - return [NSIndexPath indexPathForRow:2 inSection:section]; - - } -} - -#pragma mark - Properties - -- (NSIndexPath *)restorableSelectedIndexPath -{ - if (!_restorableSelectedIndexPath) { - // If nil, default to stats subsection. - BlogDetailsSubsection subsection = [self defaultSubsection]; - self.selectedSectionCategory = [self sectionCategoryWithSubsection:subsection blog: self.blog]; - NSUInteger section = [self findSectionIndexWithSections:self.tableSections category:self.selectedSectionCategory]; - _restorableSelectedIndexPath = [NSIndexPath indexPathForRow:0 inSection:section]; - } - - return _restorableSelectedIndexPath; -} - -- (void)setRestorableSelectedIndexPath:(NSIndexPath *)restorableSelectedIndexPath -{ - if (restorableSelectedIndexPath != nil && restorableSelectedIndexPath.section < [self.tableSections count]) { - BlogDetailsSection *section = [self.tableSections objectAtIndex:restorableSelectedIndexPath.section]; - switch (section.category) { - case BlogDetailsSectionCategoryJetpackBrandingCard: - case BlogDetailsSectionCategoryDomainCredit: { - _restorableSelectedIndexPath = nil; - } - break; - default: { - self.selectedSectionCategory = section.category; - _restorableSelectedIndexPath = restorableSelectedIndexPath; - } - break; - } - return; - } - - _restorableSelectedIndexPath = nil; -} - -#pragma mark - iOS 10 bottom padding - -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)sectionNum { - BlogDetailsSection *section = self.tableSections[sectionNum]; - BOOL isLastSection = sectionNum == self.tableSections.count - 1; - BOOL hasTitle = section.footerTitle != nil && ![section.footerTitle isEmpty]; - if (hasTitle) { - return UITableViewAutomaticDimension; - } - if (isLastSection) { - return BlogDetailSectionFooterHeight; - } - return 0; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)sectionNum { - BlogDetailsSection *section = self.tableSections[sectionNum]; - BOOL hasTitle = section.title != nil && ![section.title isEmpty]; - - if (hasTitle) { - return BlogDetailSectionTitleHeaderHeight; - } - return BlogDetailSectionsSpacing; -} - -- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { - BlogDetailsSection *detailSection = self.tableSections[section]; - NSString *footerTitle = detailSection.footerTitle; - if (footerTitle != nil) { - BlogDetailsSectionFooterView *footerView = (BlogDetailsSectionFooterView *)[tableView dequeueReusableHeaderFooterViewWithIdentifier:BlogDetailsSectionFooterIdentifier]; - // If the next section has title, gives extra spacing between two sections. - BOOL shouldShowExtraSpacing = (self.tableSections.count > section + 1) ? (self.tableSections[section + 1].title != nil): NO; - [footerView updateUIWithTitle:footerTitle shouldShowExtraSpacing:shouldShowExtraSpacing]; - return footerView; - } - - return nil; -} - -#pragma mark - Rows - -- (BlogDetailsRow *)postsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Posts", @"Noun. Title. Links to the blog's Posts screen.") - accessibilityIdentifier:@"Blog Post Row" - image:[[UIImage imageNamed:@"site-menu-posts"] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showPostListFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)pagesRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Pages", @"Noun. Title. Links to the blog's Pages screen.") - accessibilityIdentifier:@"Site Pages Row" - image:[UIImage imageNamed:@"site-menu-pages"] - callback:^{ - [weakSelf showPageListFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)mediaRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Media", @"Noun. Title. Links to the blog's Media library.") - accessibilityIdentifier:@"Media Row" - image:[UIImage imageNamed:@"site-menu-media"] - callback:^{ - [weakSelf showMediaLibraryFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)commentsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Comments", @"Noun. Title. Links to the blog's Comments screen.") - image:[[UIImage imageNamed:@"site-menu-comments"] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showCommentsFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)statsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *statsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Stats", @"Noun. Abbv. of Statistics. Links to a blog's Stats screen.") - accessibilityIdentifier:@"Stats Row" - image:[UIImage imageNamed:@"site-menu-stats"] - callback:^{ - [weakSelf showStatsFromSource:BlogDetailsNavigationSourceRow]; - }]; - return statsRow; -} - -- (BlogDetailsRow *)blazeRow -{ - __weak __typeof(self) weakSelf = self; - CGSize iconSize = CGSizeMake(BlogDetailGridiconSize, BlogDetailGridiconSize); - UIImage *blazeIcon = [[UIImage imageNamed:@"icon-blaze"] resizedTo:iconSize format:ScalingModeScaleAspectFit]; - BlogDetailsRow *blazeRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Blaze", @"Noun. Links to a blog's Blaze screen.") - accessibilityIdentifier:@"Blaze Row" - image:[blazeIcon imageFlippedForRightToLeftLayoutDirection] - imageColor:nil - renderingMode:UIImageRenderingModeAlwaysOriginal - callback:^{ - [weakSelf showBlaze]; - }]; - blazeRow.showsSelectionState = [RemoteFeature enabled:RemoteFeatureFlagBlazeManageCampaigns]; - return blazeRow; -} - -- (BlogDetailsRow *)socialRow -{ - __weak __typeof(self) weakSelf = self; - - NSString *title = ObjCBridge.isWordPress - ? NSLocalizedString(@"Sharing", @"Noun. Title. Links to a blog's sharing options.") - : [BlogDetailsViewControllerStrings socialRowTitle]; - - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:title - image:[UIImage imageNamed:@"site-menu-social"] - callback:^{ - [weakSelf showSharingFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)siteMonitoringRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:[BlogDetailsViewControllerStrings siteMonitoringRowTitle] - accessibilityIdentifier:@"Site Monitoring Row" - image:[UIImage imageNamed:@"tool"] - callback:^{ - [weakSelf showSiteMonitoring]; - }]; - return row; -} - -- (BlogDetailsRow *)activityRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity Log", @"Noun. Links to a blog's Activity screen.") - accessibilityIdentifier:@"Activity Log Row" - image:[UIImage imageNamed:@"site-menu-activity"] - callback:^{ - [weakSelf showActivity]; - }]; - return row; -} - -- (BlogDetailsRow *)backupRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Backup", @"Noun. Links to a blog's Jetpack Backups screen.") - accessibilityIdentifier:@"Backup Row" - image:[UIImage gridiconOfType:GridiconTypeCloudOutline] - callback:^{ - [weakSelf showBackup]; - }]; - return row; -} - -- (BlogDetailsRow *)scanRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Scan", @"Noun. Links to a blog's Jetpack Scan screen.") - accessibilityIdentifier:@"Scan Row" - image:[UIImage imageNamed:@"jetpack-scan-menu-icon"] - callback:^{ - [weakSelf showScan]; - }]; - return row; -} - -- (BlogDetailsRow *)usersRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Users", @"Noun. Title. Links to the user management feature.") - accessibilityIdentifier:@"Users Row" - image:[UIImage imageNamed:@"site-menu-people"] - callback:^{ - [weakSelf showUsers]; - }]; - return row; -} - -- (BlogDetailsRow *)pluginsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plugins", @"Noun. Title. Links to the plugin management feature.") - image:[UIImage imageNamed:@"site-menu-plugins"] - callback:^{ - [weakSelf showPlugins]; - }]; - return row; -} - -- (BlogDetailsRow *)themesRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Themes", @"Themes option in the blog details") - image:[UIImage imageNamed:@"site-menu-themes"] - callback:^{ - [weakSelf showThemes]; - }]; - return row; -} - -- (BlogDetailsRow *)menuRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Menus", @"Menus option in the blog details") - image:[[UIImage gridiconOfType:GridiconTypeMenus] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showMenus]; - }]; - return row; -} - -- (BlogDetailsRow *)domainsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Domains", @"Noun. Title. Links to the Domains screen.") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Domains Row" - image:[UIImage imageNamed:@"site-menu-domains"] - callback:^{ - [weakSelf showDomainsFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)applicationPasswordRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Application Passwords", comment: @"Link to Application Passwords section") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Application Passwords Row" - image:[UIImage systemImageNamed:@"key"] - callback:^{ - [weakSelf showApplicationPasswords]; - }]; - return row; -} - -- (BlogDetailsRow *)siteSettingsRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Site Settings", @"Noun. Title. Links to the blog's Settings screen.") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Settings Row" - image:[UIImage imageNamed:@"site-menu-settings"] - callback:^{ - [weakSelf showSettingsFromSource:BlogDetailsNavigationSourceRow]; - }]; - return row; -} - -- (BlogDetailsRow *)adminRow -{ - __weak __typeof(self) weakSelf = self; - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:[self adminRowTitle] - image:[UIImage gridiconOfType:GridiconTypeMySites] - callback:^{ - [weakSelf showViewAdmin]; - [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; - }]; - UIImage *image = [[UIImage gridiconOfType:GridiconTypeExternal withSize:CGSizeMake(BlogDetailGridiconAccessorySize, BlogDetailGridiconAccessorySize)] imageFlippedForRightToLeftLayoutDirection]; - UIImageView *accessoryView = [[UIImageView alloc] initWithImage:image]; - accessoryView.tintColor = [WPStyleGuide cellGridiconAccessoryColor]; // Match disclosure icon color. - row.accessoryView = accessoryView; - row.showsSelectionState = NO; - return row; -} - #pragma mark - Data Model setup - (void)reloadTableViewPreservingSelection { - // Configure and reload table data when appearing to ensure pending comment count is updated - [self.tableView reloadData]; - - // Check if the last selected category index needs to be updated after a dynamic section is activated and displayed. - // and Use Domain are dynamic section, which means they can be removed or hidden at any time. - NSUInteger sectionIndex = [self findSectionIndexWithSections:self.tableSections category:self.selectedSectionCategory]; - - if (sectionIndex != NSNotFound && self.restorableSelectedIndexPath.section != sectionIndex) { - BlogDetailsSection *section = [self.tableSections objectAtIndex:sectionIndex]; - - NSUInteger row = 0; - - // Use Domain cases we want to select the first row on the next available section - switch (section.category) { - case BlogDetailsSectionCategoryJetpackBrandingCard: - case BlogDetailsSectionCategoryDomainCredit: { - BlogDetailsSubsection subsection = [self defaultSubsection]; - BlogDetailsSectionCategory category = [self sectionCategoryWithSubsection:subsection blog: self.blog]; - sectionIndex = [self findSectionIndexWithSections:self.tableSections category:category]; - } - break; - default: - row = self.restorableSelectedIndexPath.row; - break; - } - - self.restorableSelectedIndexPath = [NSIndexPath indexPathForRow:row inSection:sectionIndex]; - } - - BOOL isValidIndexPath = self.restorableSelectedIndexPath.section < self.tableView.numberOfSections && - self.restorableSelectedIndexPath.row < [self.tableView numberOfRowsInSection:self.restorableSelectedIndexPath.section]; - if (isValidIndexPath && [self isSplitViewDisplayed]) { - // And finally we'll reselect the selected row, if there is one - [self.tableView selectRowAtIndexPath:self.restorableSelectedIndexPath - animated:NO - scrollPosition:[self optimumScrollPositionForIndexPath:self.restorableSelectedIndexPath]]; - } + [self.tableViewModel reloadTableViewPreservingSelection:self.tableView]; } - (UITableViewScrollPosition)optimumScrollPositionForIndexPath:(NSIndexPath *)indexPath @@ -964,482 +163,13 @@ - (UITableViewScrollPosition)optimumScrollPositionForIndexPath:(NSIndexPath *)in - (void)configureTableViewData { - NSMutableArray *marr = [NSMutableArray array]; - - // TODO: Add the SoTW card here. - if ([self shouldShowSotW2023Card]) { - [marr addNullableObject:[self sotw2023SectionViewModel]]; - } - - if ([self shouldShowJetpackInstallCard]) { - [marr addNullableObject:[self jetpackInstallSectionViewModel]]; - } - - if (self.shouldShowTopJetpackBrandingMenuCard == YES) { - [marr addNullableObject:[self jetpackCardSectionViewModel]]; - } - - if ([self isDashboardEnabled] && [self isSplitViewDisplayed]) { - [marr addNullableObject:[self homeSectionViewModel]]; - } - - if (ObjCBridge.isWordPress) { - if ([self shouldAddJetpackSection]) { - [marr addNullableObject:[self jetpackSectionViewModel]]; - } - - if ([self shouldAddGeneralSection]) { - [marr addNullableObject:[self generalSectionViewModel]]; - } - - [marr addNullableObject:[self publishTypeSectionViewModel]]; - - if ([self shouldAddPersonalizeSection]) { - [marr addNullableObject:[self personalizeSectionViewModel]]; - } - - [marr addNullableObject:[self configurationSectionViewModel]]; - [marr addNullableObject:[self externalSectionViewModel]]; - } else { - [marr addNullableObject:[self contentSectionViewModel]]; - [marr addNullableObject:[self trafficSectionViewModel]]; - [marr addObjectsFromArray:[self maintenanceSectionViewModel]]; - } - - if ([self.blog supports:BlogFeatureRemovable]) { - [marr addNullableObject:[self removeSiteSectionViewModel]]; - } - - if (self.shouldShowBottomJetpackBrandingMenuCard == YES) { - [marr addNullableObject:[self jetpackCardSectionViewModel]]; - } - - // Assign non mutable copy. - self.tableSections = [NSArray arrayWithArray:marr]; + [self.tableViewModel configureTableViewData]; } - (Boolean)isSplitViewDisplayed { return self.isSidebarModeEnabled; } -/// This section is available on Jetpack only. -- (BlogDetailsSection *)contentSectionViewModel -{ - NSMutableArray *rows = [NSMutableArray array]; - - [rows addObject:[self postsRow]]; - if ([self.blog supports:BlogFeaturePages]) { - [rows addObject:[self pagesRow]]; - } - [rows addObject:[self mediaRow]]; - [rows addObject:[self commentsRow]]; - - NSString *title = self.isSidebarModeEnabled ? nil : [BlogDetailsViewControllerStrings contentSectionTitle]; - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryContent]; -} - -/// This section is available on Jetpack only. -- (BlogDetailsSection *)trafficSectionViewModel -{ - // Init rows - NSMutableArray *rows = [NSMutableArray array]; - - // Stats row - if ([self.blog isViewingStatsAllowed]) { - [rows addObject:[self statsRow]]; - } - - if ([self shouldShowSubscribersRow]) { - [rows addObject:[self makeSubscribersRow]]; - } - - // Social row - if ([self shouldAddSharingRow]) { - [rows addObject:[self socialRow]]; - } - - // Blaze row - if ([self shouldShowBlaze]) { - [rows addObject:[self blazeRow]]; - } - - if (rows.count == 0) { - return nil; - } - - // Return - NSString *title = [BlogDetailsViewControllerStrings trafficSectionTitle]; - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryTraffic]; -} - -/// Returns a list of sections. Available on Jetpack only. -- (NSArray *)maintenanceSectionViewModel -{ - // Init array - NSMutableArray *sections = [NSMutableArray array]; - NSMutableArray *firstSectionRows = [NSMutableArray array]; - NSMutableArray *secondSectionRows = [NSMutableArray array]; - NSMutableArray *thirdSectionRows = [NSMutableArray array]; - - // The 1st section - if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { - [firstSectionRows addObject:[self activityRow]]; - } - if ([self.blog isBackupsAllowed]) { - [firstSectionRows addObject:[self backupRow]]; - } - if ([self.blog isScanAllowed]) { - [firstSectionRows addObject:[self scanRow]]; - } - if ([RemoteFeature enabled:RemoteFeatureFlagSiteMonitoring] && [self.blog supports:BlogFeatureSiteMonitoring]) { - [firstSectionRows addObject:[self siteMonitoringRow]]; - } - - // The 2nd section - if ([self shouldAddPeopleRow]) { - [secondSectionRows addObject:[self makePeopleRow]]; - } - if ([self shouldAddUsersRow]) { - [secondSectionRows addObject:[self usersRow]]; - } - if ([self shouldAddPluginsRow]) { - [secondSectionRows addObject:[self pluginsRow]]; - } - if ([self.blog supports:BlogFeatureThemeBrowsing] && ![self.blog isWPForTeams]) { - [secondSectionRows addObject:[self themesRow]]; - } - if ([self.blog supports:BlogFeatureMenus]) { - [secondSectionRows addObject:[self menuRow]]; - } - if ([self shouldAddDomainRegistrationRow]) { - [secondSectionRows addObject:[self domainsRow]]; - } - if ([Feature enabled:FeatureFlagAllowApplicationPasswords]) { - [secondSectionRows addObject:[self applicationPasswordRow]]; - } - - [secondSectionRows addObject:[self siteSettingsRow]]; - - // Third section - if ([self shouldDisplayLinkToWPAdmin]) { - [thirdSectionRows addObject:[self adminRow]]; - } - - // Add sections - NSString *sectionTitle = [BlogDetailsViewControllerStrings maintenanceSectionTitle]; - BOOL shouldAddSectionTitle = YES; - if ([firstSectionRows count] > 0) { - BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:sectionTitle - andRows:firstSectionRows - category:BlogDetailsSectionCategoryMaintenance]; - [sections addObject:section]; - shouldAddSectionTitle = NO; - } - if ([secondSectionRows count] > 0) { - NSString *title = shouldAddSectionTitle ? sectionTitle : nil; - BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:title - andRows:secondSectionRows - category:BlogDetailsSectionCategoryMaintenance]; - [sections addObject:section]; - shouldAddSectionTitle = NO; - } - if ([thirdSectionRows count] > 0) { - NSString *title = shouldAddSectionTitle ? sectionTitle : nil; - BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:title - andRows:thirdSectionRows - category:BlogDetailsSectionCategoryMaintenance]; - [sections addObject:section]; - } - - // Return - return sections; -} - -- (BlogDetailsSection *)homeSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Home", @"Noun. Links to a blog's dashboard screen.") - accessibilityIdentifier:@"Home Row" - image:[UIImage imageNamed:@"site-menu-home"] - callback:^{ - [weakSelf showDashboard]; - }]]; - - return [[BlogDetailsSection alloc] initWithTitle:nil andRows:rows category:BlogDetailsSectionCategoryHome]; -} - -- (BlogDetailsSection *)generalSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - - if ([self.blog isViewingStatsAllowed]) { - [rows addObject:[self statsRow]]; - } - - if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity", @"Noun. Links to a blog's Activity screen.") - image:[UIImage imageNamed:@"site-menu-activity"] - callback:^{ - [weakSelf showActivity]; - }]]; - } - - if ([self shouldShowBlaze]) { - [rows addObject:[self blazeRow]]; - } - - if (rows.count == 0) { - return nil; - } - - return [[BlogDetailsSection alloc] initWithTitle:nil andRows:rows category:BlogDetailsSectionCategoryGeneral]; -} - -- (BlogDetailsSection *)jetpackSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - - if ([self.blog isViewingStatsAllowed]) { - [rows addObject:[self statsRow]]; - } - - if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity Log", @"Noun. Links to a blog's Activity screen.") - accessibilityIdentifier:@"Activity Log Row" - image:[UIImage imageNamed:@"site-menu-activity"] - callback:^{ - [weakSelf showActivity]; - }]]; - } - - - if ([self.blog isBackupsAllowed]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Backup", @"Noun. Links to a blog's Jetpack Backups screen.") - accessibilityIdentifier:@"Backup Row" - image:[UIImage gridiconOfType:GridiconTypeCloudOutline] - callback:^{ - [weakSelf showBackup]; - }]]; - } - - if ([self.blog isScanAllowed]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Scan", @"Noun. Links to a blog's Jetpack Scan screen.") - accessibilityIdentifier:@"Scan Row" - image:[UIImage imageNamed:@"jetpack-scan-menu-icon"] - callback:^{ - [weakSelf showScan]; - }]]; - } - - if ([self.blog supports:BlogFeatureJetpackSettings]) { - BlogDetailsRow *settingsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Jetpack Settings", @"Noun. Title. Links to the blog's Settings screen.") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Jetpack Settings Row" - image:[UIImage imageNamed:@"site-menu-settings"] - callback:^{ - [weakSelf showJetpackSettings]; - }]; - - [rows addObject:settingsRow]; - } - - if ([self shouldShowBlaze]) { - [rows addObject:[self blazeRow]]; - } - - if (rows.count == 0) { - return nil; - } - - NSString *title = @""; - - if ([self.blog supports:BlogFeatureJetpackSettings]) { - title = NSLocalizedString(@"Jetpack", @"Section title for the publish table section in the blog details screen"); - } - - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryJetpack]; -} - -- (BlogDetailsSection *)publishTypeSectionViewModel -{ - NSMutableArray *rows = [NSMutableArray array]; - - [rows addObject:[self postsRow]]; - [rows addObject:[self mediaRow]]; - if ([self.blog supports:BlogFeaturePages]) { - [rows addObject:[self pagesRow]]; - } - [rows addObject:[self commentsRow]]; - - NSString *title = NSLocalizedString(@"Publish", @"Section title for the publish table section in the blog details screen"); - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryContent]; -} - -- (BlogDetailsSection *)personalizeSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - if ([self.blog supports:BlogFeatureThemeBrowsing] && ![self.blog isWPForTeams]) { - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Themes", @"Themes option in the blog details") - image:[UIImage imageNamed:@"site-menu-themes"] - callback:^{ - [weakSelf showThemes]; - }]; - [rows addObject:row]; - } - if ([self.blog supports:BlogFeatureMenus]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Menus", @"Menus option in the blog details") - image:[[UIImage gridiconOfType:GridiconTypeMenus] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showMenus]; - }]]; - } - NSString *title =NSLocalizedString(@"Personalize", @"Section title for the personalize table section in the blog details screen."); - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryPersonalize]; -} - -- (BlogDetailsSection *)configurationSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - - if ([self shouldAddMeRow]) { - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Me", @"Noun. Title. Links to the Me screen.") - image:[UIImage gridiconOfType:GridiconTypeUserCircle] - callback:^{ - [weakSelf showMe]; - }]; - [self downloadGravatarImageFor:row forceRefresh: NO]; - self.meRow = row; - [rows addObject:row]; - } - - if ([self shouldAddSharingRow]) { - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Sharing", @"Noun. Title. Links to a blog's sharing options.") - image:[UIImage imageNamed:@"site-menu-social"] - callback:^{ - [weakSelf showSharingFromSource:BlogDetailsNavigationSourceRow]; - }]; - [rows addObject:row]; - } - - if ([self shouldAddPeopleRow]) { - [rows addObject:[self makePeopleRow]]; - } - - if ([self shouldAddUsersRow]) { - [rows addObject:[self usersRow]]; - } - - if ([self shouldAddPluginsRow]) { - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plugins", @"Noun. Title. Links to the plugin management feature.") - image:[UIImage imageNamed:@"site-menu-plugins"] - callback:^{ - [weakSelf showPlugins]; - }]]; - } - - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Site Settings", @"Noun. Title. Links to the blog's Settings screen.") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Settings Row" - image:[UIImage imageNamed:@"site-menu-settings"] - callback:^{ - [weakSelf showSettingsFromSource:BlogDetailsNavigationSourceRow]; - }]; - - [rows addObject:row]; - - if ([self shouldAddDomainRegistrationRow]) { - BlogDetailsRow *domainsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Domains", @"Noun. Title. Links to the Domains screen.") - identifier:BlogDetailsSettingsCellIdentifier - accessibilityIdentifier:@"Domains Row" - image:[UIImage imageNamed:@"site-menu-domains"] - callback:^{ - [weakSelf showDomainsFromSource:BlogDetailsNavigationSourceRow]; - }]; - [rows addObject:domainsRow]; - } - - NSString *title = NSLocalizedString(@"Configure", @"Section title for the configure table section in the blog details screen"); - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryConfigure]; -} - -- (BlogDetailsSection *)externalSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - BlogDetailsRow *viewSiteRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"View Site", @"Action title. Opens the user's site in an in-app browser") - image:[UIImage gridiconOfType:GridiconTypeGlobe] - callback:^{ - [weakSelf showViewSiteFromSource:BlogDetailsNavigationSourceRow]; - }]; - viewSiteRow.showsSelectionState = NO; - [rows addObject:viewSiteRow]; - - if ([self shouldDisplayLinkToWPAdmin]) { - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:[self adminRowTitle] - image:[UIImage gridiconOfType:GridiconTypeMySites] - callback:^{ - [weakSelf showViewAdmin]; - [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; - }]; - UIImage *image = [[UIImage gridiconOfType:GridiconTypeExternal withSize:CGSizeMake(BlogDetailGridiconAccessorySize, BlogDetailGridiconAccessorySize)] imageFlippedForRightToLeftLayoutDirection]; - UIImageView *accessoryView = [[UIImageView alloc] initWithImage:image]; - accessoryView.tintColor = [WPStyleGuide cellGridiconAccessoryColor]; // Match disclosure icon color. - row.accessoryView = accessoryView; - row.showsSelectionState = NO; - [rows addObject:row]; - } - - NSString *title = NSLocalizedString(@"External", @"Section title for the external table section in the blog details screen"); - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryExternal]; -} - -- (BlogDetailsSection *)removeSiteSectionViewModel -{ - __weak __typeof(self) weakSelf = self; - NSMutableArray *rows = [NSMutableArray array]; - BlogDetailsRow *removeSiteRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Remove Site", @"Button to remove a site from the app") - identifier:BlogDetailsRemoveSiteCellIdentifier - image:nil - callback:^{ - [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; - [weakSelf showRemoveSiteAlert]; - }]; - removeSiteRow.showsSelectionState = NO; - removeSiteRow.forDestructiveAction = YES; - [rows addObject:removeSiteRow]; - - return [[BlogDetailsSection alloc] initWithTitle:nil andRows:rows category:BlogDetailsSectionCategoryRemoveSite]; - -} - -- (NSString *)adminRowTitle -{ - if (self.blog.isHostedAtWPcom) { - return NSLocalizedString(@"Dashboard", @"Action title. Noun. Opens the user's WordPress.com dashboard in an external browser."); - } else { - return NSLocalizedString(@"WP Admin", @"Action title. Noun. Opens the user's WordPress Admin in an external browser."); - } -} - -// Non .com users and .com user whose accounts were created -// before LastWPAdminAccessDate should have access to WPAdmin -- (BOOL)shouldDisplayLinkToWPAdmin -{ - if (!self.blog.isHostedAtWPcom) { - return YES; - } - NSDate *hideWPAdminDate = [NSDate dateWithISO8601String:HideWPAdminDate]; - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; - return [defaultAccount.dateCreated compare:hideWPAdminDate] == NSOrderedAscending; -} - #pragma mark Site Switching - (void)switchToBlog:(Blog*)blog @@ -1452,155 +182,7 @@ - (void)switchToBlog:(Blog*)blog - (void)showInitialDetailsForBlog { - if (![self isSplitViewDisplayed]) { - return; - } - - self.restorableSelectedIndexPath = nil; - - BlogDetailsSubsection subsection = [self defaultSubsection]; - switch (subsection) { - case BlogDetailsSubsectionHome: - [self showDetailViewForSubsection:BlogDetailsSubsectionHome]; - break; - case BlogDetailsSubsectionStats: - [self showDetailViewForSubsection:BlogDetailsSubsectionStats]; - break; - case BlogDetailsSubsectionPosts: - [self showDetailViewForSubsection: BlogDetailsSubsectionPosts]; - break; - default: - break; - } -} - -#pragma mark - Table view data source - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return self.tableSections.count; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - BlogDetailsSection *detailSection = [self.tableSections objectAtIndex:section]; - return [detailSection.rows count]; -} - -- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath -{ - BlogDetailsSection *section = [self.tableSections objectAtIndex:indexPath.section]; - BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; - cell.textLabel.text = row.title; - cell.accessibilityIdentifier = row.accessibilityIdentifier ?: row.identifier; - cell.detailTextLabel.text = row.detail; - cell.imageView.image = row.image; - cell.imageView.tintColor = row.imageColor; - if (row.accessoryView) { - cell.accessoryView = row.accessoryView; - } -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - BlogDetailsSection *section = [self.tableSections objectAtIndex:indexPath.section]; - - if (section.category == BlogDetailsSectionCategorySotW2023Card) { - SotWTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsSotWCardCellIdentifier]; - __weak __typeof(self) weakSelf = self; - [cell configureOnCardHidden:^{ - [weakSelf configureTableViewData]; - [weakSelf reloadTableViewPreservingSelection]; - }]; - - return cell; - } - - if (section.category == BlogDetailsSectionCategoryJetpackInstallCard) { - JetpackRemoteInstallTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; - [cell configureWithBlog:self.blog viewController:self]; - return cell; - } - - if (section.category == BlogDetailsSectionCategoryMigrationSuccess) { - MigrationSuccessCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsMigrationSuccessCellIdentifier]; - if (self.isSidebarModeEnabled) { - [cell configureForSidebarMode]; - } - [cell configureWithViewController:self]; - return cell; - } - - if (section.category == BlogDetailsSectionCategoryJetpackBrandingCard) { - JetpackBrandingMenuCardCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; - [cell configureWithViewController:self]; - return cell; - } - - BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.identifier]; - - if (cell == nil) { - DDLogError(@"Cell with identifier '%@' at index path '%@' is nil", row.identifier, indexPath); - } - - cell.accessibilityHint = row.accessibilityHint; - cell.accessoryView = nil; - cell.textLabel.textAlignment = NSTextAlignmentNatural; - - if (row.forDestructiveAction) { - cell.accessoryType = UITableViewCellAccessoryNone; - [WPStyleGuide configureTableViewDestructiveActionCell:cell]; - } else { - if (row.showsDisclosureIndicator) { - cell.accessoryType = [self isSplitViewDisplayed] ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator; - } else { - cell.accessoryType = UITableViewCellAccessoryNone; - } - [WPStyleGuide configureTableViewCell:cell]; - } - - [self configureCell:cell atIndexPath:indexPath]; - - return cell; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - BlogDetailsSection *section = [self.tableSections objectAtIndex:indexPath.section]; - BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; - row.callback(); - - if (row.showsSelectionState) { - self.restorableSelectedIndexPath = indexPath; - } else { - if (![self isSplitViewDisplayed]) { - // Deselect current row when not in split view layout - [tableView deselectRowAtIndexPath:indexPath animated:YES]; - } else { - // Reselect the previous row - [tableView selectRowAtIndexPath:self.restorableSelectedIndexPath - animated:YES - scrollPosition:UITableViewScrollPositionNone]; - } - } -} - -- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section -{ - BlogDetailsSection *detailSection = [self.tableSections objectAtIndex:section]; - return detailSection.title; -} - -- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(nonnull NSIndexPath *)indexPath -{ - BOOL isNewSelection = (indexPath != tableView.indexPathForSelectedRow); - - if (isNewSelection) { - return indexPath; - } else { - return nil; - } + [self.tableViewModel showInitialDetailsForBlog]; } #pragma mark - Private methods @@ -1782,22 +364,4 @@ - (void)pulledToRefreshWith:(UIRefreshControl *)refreshControl onCompletion:( vo }]; } -#pragma mark - Constants - -+ (NSString *)userInfoShowPickerKey { - return @"show-picker"; -} - -+ (NSString *)userInfoSiteMonitoringTabKey { - return @"site-monitoring-tab"; -} - -+ (NSString *)userInfoShowManagemenetScreenKey { - return @"show-manage-plugins"; -} - -+ (NSString *)userInfoSourceKey { - return @"source"; -} - @end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/SoTW 2023/SOTWCardView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/SoTW 2023/SOTWCardView.swift index b4ba2ec7fccd..3628b1fe46e9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/SoTW 2023/SOTWCardView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/SoTW 2023/SOTWCardView.swift @@ -141,16 +141,6 @@ public class SotWTableViewCell: UITableViewCell { extension BlogDetailsViewController { - @objc public func sotw2023SectionViewModel() -> BlogDetailsSection { - let row = BlogDetailsRow() - row.callback = {} - let section = BlogDetailsSection(title: nil, - rows: [row], - footerTitle: nil, - category: .sotW2023Card) - return section - } - @objc public func shouldShowSotW2023Card() -> Bool { guard AppConfiguration.isWordPress && RemoteFeatureFlag.wordPressSotWCard.enabled() else { return false diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift index 3b3209f02140..9540e5d367b1 100644 --- a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift @@ -900,12 +900,12 @@ extension MySiteViewController: BlogDetailsPresentationDelegate { /// - Parameters: /// - subsection: The specific subsection to show. /// - func showBlogDetailsSubsection(_ subsection: BlogDetailsSubsection, userInfo: [AnyHashable: Any] = [:]) { + func showBlogDetailsSubsection(_ subsection: BlogDetailsRowKind, userInfo: [String: Any] = [:]) { blogDetailsViewController?.showDetailView(for: subsection, userInfo: userInfo) } func showBlogDetailsMeSubsection() -> MeViewController? { - blogDetailsViewController?.showDetailViewForMeSubsection(userInfo: [:]) + blogDetailsViewController?.showDetailViewForMe(userInfo: [:]) } // TODO: Refactor presentation from routes diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift index 9c9d23b46e12..859beff5da6a 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift @@ -12,25 +12,6 @@ extension BlogDetailsViewController { return presenter.shouldShowBottomCard() } - @objc public func jetpackCardSectionViewModel() -> BlogDetailsSection { - let row = BlogDetailsRow() - row.callback = { [weak self] in - self?.showJetpackOverlay() - } - return BlogDetailsSection( - title: nil, - rows: [row], - footerTitle: nil, - category: .jetpackBrandingCard - ) - } - - private func showJetpackOverlay() { - let presenter = JetpackBrandingMenuCardPresenter(blog: blog) - JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .card, blog: blog) - presenter.trackCardTapped() - } - func reloadTableView() { configureTableViewData() reloadTableViewPreservingSelection() diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift index dd47c8b06d84..b193c28a285d 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift @@ -70,16 +70,6 @@ public class JetpackRemoteInstallTableViewCell: UITableViewCell { extension BlogDetailsViewController: JetpackRemoteInstallDelegate { - @objc public func jetpackInstallSectionViewModel() -> BlogDetailsSection { - let row = BlogDetailsRow() - row.callback = {} - let section = BlogDetailsSection(title: nil, - rows: [row], - footerTitle: nil, - category: .jetpackInstallCard) - return section - } - func jetpackRemoteInstallCompleted() { dismiss(animated: true) } diff --git a/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift b/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift index a0eb6cbafcd2..fad661dc9586 100644 --- a/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift +++ b/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift @@ -67,7 +67,7 @@ public class MySitesCoordinator: NSObject { // MARK: - Blog Details - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection?, userInfo: [AnyHashable: Any]) { + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsRowKind?, userInfo: [String: Any]) { showRootViewController() mySiteViewController.blog = blog diff --git a/WordPress/Classes/ViewRelated/System/Sidebar/SiteMenuViewController.swift b/WordPress/Classes/ViewRelated/System/Sidebar/SiteMenuViewController.swift index 5a99fd08ae55..ec182a5daa23 100644 --- a/WordPress/Classes/ViewRelated/System/Sidebar/SiteMenuViewController.swift +++ b/WordPress/Classes/ViewRelated/System/Sidebar/SiteMenuViewController.swift @@ -79,57 +79,16 @@ final class SiteMenuViewController: UIViewController { tipObserver = nil } - func showSubsection(_ subsection: BlogDetailsSubsection, userInfo: [AnyHashable: Any]) { + func showSubsection(_ subsection: BlogDetailsRowKind, userInfo: [String: Any]) { blogDetailsVC.showDetailView(for: subsection, userInfo: userInfo) } } // Updates the `BlogDetailsViewController` style to match the native sidebar style. private final class SiteMenuListViewController: BlogDetailsViewController { - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let title = super.tableView(tableView, titleForHeaderInSection: section) - return title == nil ? 0 : 48 - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let title = super.tableView(tableView, titleForHeaderInSection: section) else { - return nil - } - let label = UILabel() - label.font = UIFont.preferredFont(forTextStyle: .headline) - label.text = title - - let headerView = UIView() - headerView.addSubview(label) - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20), - label.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -8), - label.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: 20) - ]) - return headerView - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = super.tableView(tableView, cellForRowAt: indexPath) - - cell.textLabel?.font = .preferredFont(forTextStyle: .body) - cell.backgroundColor = .clear - cell.selectedBackgroundView = { - let backgroundView = UIView() - backgroundView.backgroundColor = .secondarySystemFill - backgroundView.layer.cornerRadius = DesignConstants.radius(.large) - backgroundView.layer.cornerCurve = .continuous - - let container = UIView() - container.addSubview(backgroundView) - backgroundView.pinEdges(insets: UIEdgeInsets(.horizontal, 16)) - return container - }() - cell.focusStyle = .custom - cell.focusEffect = nil - - return cell + override func viewDidLoad() { + super.viewDidLoad() + self.tableViewModel.useSiteMenuStyle = true } }