From c09ac3598fb0a022f994e4eadeecfbb13d73f126 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 29 Jan 2026 21:47:28 +1300 Subject: [PATCH 1/9] Show error messages on the custom post types screens --- .../CustomPostTypes/CustomPostListView.swift | 42 +++++++++++++++---- .../CustomPostListViewModel.swift | 17 +++++++- .../CustomPostTypes/CustomPostTypesView.swift | 8 +++- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 80f56408090f..8df6d1ec16e4 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -27,6 +27,8 @@ struct CustomPostListView: View { EmptyStateView(emptyText, systemImage: "doc.text") } else if viewModel.shouldDisplayInitialLoading { ProgressView() + } else if let error = viewModel.errorToDisplay() { + EmptyStateView.failure(error: error) } } .refreshable { @@ -58,7 +60,10 @@ private struct PaginatedList: View { } } - makeFooterView() + Section { + makeFooterView() + } + .listSectionSeparator(.hidden) } .listStyle(.plain) } @@ -80,6 +85,7 @@ private struct PaginatedList: View { do { try await onLoadNextPage() } catch { + DDLogError("Failed to load next page: \(error)") self.loadMoreError = error } } @@ -91,15 +97,16 @@ private struct PaginatedList: View { .progressViewStyle(.circular) .frame(maxWidth: .infinity, minHeight: 44, alignment: .center) .id(UUID()) // A hack to show the ProgressView after cell reusing. - } else if loadMoreError != nil { - Button { - Task { await loadNextPage() } - } label: { - HStack { - Image(systemName: "exclamationmark.circle") - Text(SharedStrings.Button.retry) + } else if let loadMoreError { + VStack { + Text(verbatim: loadMoreError.localizedDescription) + Button { + Task { await loadNextPage() } + } label: { + Text(verbatim: SharedStrings.Button.retry) } - } + .buttonStyle(.borderedProminent) + }.frame(maxWidth: .infinity, alignment: .center) } } } @@ -291,3 +298,20 @@ private enum Strings { onSelectPost: { _ in } ) } + +#Preview("Load Next Page Error") { + PaginatedList( + items: [ + .stale( + id: 1, + post: CustomPostCollectionDisplayPost( + date: .now, + title: "Published Post", + excerpt: "This post has stale data and is being refreshed." + ) + ), + ], + onLoadNextPage: { throw CollectionError.DatabaseError(errMessage: "SQL error") }, + onSelectPost: { _ in }, + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index ee935c70e50b..0b4ee85e1975 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -16,6 +16,7 @@ final class CustomPostListViewModel: ObservableObject { @Published private(set) var items: [CustomPostCollectionItem] = [] @Published private(set) var listInfo: ListInfo? + @Published private var error: Error? var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false @@ -25,6 +26,10 @@ final class CustomPostListViewModel: ObservableObject { items.isEmpty && listInfo?.isSyncing == true } + func errorToDisplay() -> Error? { + items.isEmpty ? error : nil + } + init( client: WordPressClient, service: WpSelfHostedService, @@ -49,7 +54,8 @@ final class CustomPostListViewModel: ObservableObject { do { _ = try await collection?.refresh() } catch { - DDLogError("Pull to refresh failed: \(error)") + DDLogError("Failed to refresh posts: \(error)") + self.show(error: error) } } @@ -95,6 +101,15 @@ final class CustomPostListViewModel: ObservableObject { } } } + + private func show(error: Error) { + self.error = error + + if !items.isEmpty { + // Show an error notice, on top of the list content. + Notice(error: error).post() + } + } } struct CustomPostCollectionDisplayPost: Equatable { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index ae680f2d310e..b2dce67db70b 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -63,7 +63,12 @@ struct CustomPostTypesView: View { _ = try await self.collection.fetch() await refresh() } catch { - self.error = error + DDLogError("Failed to query stored post types: \(error)") + if types.isEmpty { + self.error = error + } else { + Notice(error: error).post() + } } } } @@ -83,6 +88,7 @@ struct CustomPostTypesView: View { $0.1.slug < $1.1.slug } } catch { + DDLogError("Failed to fetch post types: \(error)") self.error = error } } From 23852776c7d0a3a4b0ad36534e1718f474760a57 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 9 Feb 2026 21:48:34 +1300 Subject: [PATCH 2/9] Align custom posts UI with Posts & Pages --- Modules/Sources/WordPressCore/ApiCache.swift | 20 +- .../WordPressCore/WordPressClient.swift | 26 +-- .../WordPressUI/Views/AdaptiveTabBar.swift | 12 +- .../Views/AdaptiveTabBarController.swift | 4 +- WordPress/Classes/Utility/SiteStorage.swift | 47 ++++ .../BlogDetailsTableViewModel.swift | 36 ++- .../BlogDetailsViewController+Swift.swift | 65 ++++-- .../BlogDetailsViewController.swift | 1 + .../CustomPostTypes/CustomPostListView.swift | 62 +++++- .../CustomPostListViewModel.swift | 6 +- .../CustomPostTypes/CustomPostMainView.swift | 191 ---------------- .../CustomPostSearchResultView.swift | 37 +++ .../CustomPostTypes/CustomPostTabView.swift | 210 ++++++++++++++++++ .../CustomPostTypes/CustomPostTypesView.swift | 129 +++++++++-- .../ViewRelated/CustomPostTypes/Icons.swift | 115 ++++++++++ .../CustomPostTypes/PinnedPostTypeView.swift | 112 ++++++++++ .../CustomPostEditorViewController.swift | 2 +- 17 files changed, 796 insertions(+), 279 deletions(-) create mode 100644 WordPress/Classes/Utility/SiteStorage.swift delete mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift diff --git a/Modules/Sources/WordPressCore/ApiCache.swift b/Modules/Sources/WordPressCore/ApiCache.swift index 856eafdaeff9..c82e6f85eb26 100644 --- a/Modules/Sources/WordPressCore/ApiCache.swift +++ b/Modules/Sources/WordPressCore/ApiCache.swift @@ -4,9 +4,9 @@ import WordPressAPIInternal import WordPressApiCache extension WordPressApiCache { - static func bootstrap() -> WordPressApiCache? { - let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache() - instance?.startListeningForUpdates() + static func bootstrap() -> WordPressApiCache { + let instance: WordPressApiCache = .onDiskCache() ?? .memoryCache() + instance.startListeningForUpdates() return instance } @@ -59,14 +59,10 @@ extension WordPressApiCache { return cache } - private static func memoryCache() -> WordPressApiCache? { - do { - let cache = try WordPressApiCache() - _ = try cache.performMigrations() - return cache - } catch { - NSLog("Failed to create memory cache: \(error)") - return nil - } + private static func memoryCache() -> WordPressApiCache { + // Creating an in-memory database should always succeed. + let cache = try! WordPressApiCache() + _ = try! cache.performMigrations() + return cache } } diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 51fd53ed638e..69bdb56d5bb3 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -80,26 +80,26 @@ public actor WordPressClient { public let api: any WordPressClientAPI private var _cache: WordPressApiCache? - public var cache: WordPressApiCache? { + public var cache: WordPressApiCache { get { - if _cache == nil { - _cache = WordPressApiCache.bootstrap() + if let _cache { + return _cache } - return _cache + let cache = WordPressApiCache.bootstrap() + _cache = cache + return cache } } private var _service: WpSelfHostedService? - public var service: WpSelfHostedService? { - get { - if _service == nil, let cache { - do { - _service = try api.createSelfHostedService(cache: cache) - } catch { - NSLog("Failed to create service: \(error)") - } + public var service: WpSelfHostedService { + get throws { + if let _service { + return _service } - return _service + let service = try api.createSelfHostedService(cache: cache) + _service = service + return service } } diff --git a/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift b/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift index d105a5b3aed3..296f8e7e29e9 100644 --- a/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift +++ b/Modules/Sources/WordPressUI/Views/AdaptiveTabBar.swift @@ -38,13 +38,13 @@ public class AdaptiveTabBar: UIControl { // MARK: - Properties - var items: [any AdaptiveTabBarItem] = [] { + public var items: [any AdaptiveTabBarItem] = [] { didSet { refreshTabs() } } private var buttons: [TabButton] = [] - private(set) var selectedIndex: Int = 0 { + public private(set) var selectedIndex: Int = 0 { didSet { buttons[oldValue].isSelected = false buttons[selectedIndex].isSelected = true @@ -58,7 +58,7 @@ public class AdaptiveTabBar: UIControl { private var indicatorCenterXConstraint: NSLayoutConstraint? private var previousWidth: CGFloat? - public let tabBarHeight: CGFloat = 40 + public static let tabBarHeight: CGFloat = 40 // MARK: - Initialization @@ -80,7 +80,7 @@ public class AdaptiveTabBar: UIControl { stackView.pinEdges() NSLayoutConstraint.activate([ - heightAnchor.constraint(equalToConstant: tabBarHeight), + heightAnchor.constraint(equalToConstant: AdaptiveTabBar.tabBarHeight), stackView.heightAnchor.constraint(equalTo: heightAnchor) ]) @@ -161,7 +161,7 @@ public class AdaptiveTabBar: UIControl { // Calculate preferred width for each button let preferredWidths = buttons.map { - $0.systemLayoutSizeFitting(CGSize(width: UIView.noIntrinsicMetric, height: tabBarHeight)).width + $0.systemLayoutSizeFitting(CGSize(width: UIView.noIntrinsicMetric, height: AdaptiveTabBar.tabBarHeight)).width } let maxWidth = preferredWidths.max() ?? 0 @@ -188,7 +188,7 @@ public class AdaptiveTabBar: UIControl { // MARK: - Selection - func setSelectedIndex(_ index: Int, animated: Bool = true) { + public func setSelectedIndex(_ index: Int, animated: Bool = true) { guard items.indices.contains(index) else { return } UIView.performWithoutAnimation { diff --git a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift index 3f0750f2749a..52e267b7fac9 100644 --- a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift +++ b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift @@ -53,7 +53,7 @@ public final class AdaptiveTabBarController { private func setupFilterBar() { // filterBarContainer.backgroundColor = .systemGroupedBackground // .secondarySystemGroupedBackground filterBarContainer.contentView.addSubview(filterBar) - filterBar.pinEdges(.top, to: filterBarContainer.safeAreaLayoutGuide, insets: UIEdgeInsets(.top, -filterBar.tabBarHeight)) + filterBar.pinEdges(.top, to: filterBarContainer.safeAreaLayoutGuide, insets: UIEdgeInsets(.top, -AdaptiveTabBar.tabBarHeight)) filterBar.pinEdges([.horizontal, .bottom]) filterBar.addTarget(self, action: #selector(selectedFilterDidChange), for: .valueChanged) @@ -98,7 +98,7 @@ public final class AdaptiveTabBarController { viewController.navigationItem.titleView = nil viewController.view.addSubview(filterBarContainer) filterBarContainer.pinEdges([.top, .horizontal]) - viewController.additionalSafeAreaInsets = UIEdgeInsets(.top, filterBar.tabBarHeight) + viewController.additionalSafeAreaInsets = UIEdgeInsets(.top, AdaptiveTabBar.tabBarHeight) } } diff --git a/WordPress/Classes/Utility/SiteStorage.swift b/WordPress/Classes/Utility/SiteStorage.swift new file mode 100644 index 000000000000..038d0485f1e8 --- /dev/null +++ b/WordPress/Classes/Utility/SiteStorage.swift @@ -0,0 +1,47 @@ +import SwiftUI +import WordPressData + +@propertyWrapper +struct SiteStorage: DynamicProperty { + @AppStorage private var data: Data + private let defaultValue: Value + + var wrappedValue: Value { + get { + (try? JSONDecoder().decode(Value.self, from: data)) ?? defaultValue + } + nonmutating set { + data = (try? JSONEncoder().encode(newValue)) ?? Data() + } + } + + var projectedValue: Binding { + Binding(get: { wrappedValue }, set: { wrappedValue = $0 }) + } + + init(wrappedValue: Value, _ key: String, blog: TaggedManagedObjectID, + store: UserDefaults? = nil) { + self.defaultValue = wrappedValue + let scopedKey = SiteStorageReader.scopedKey(key, blog: blog) + _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) + } +} + +enum SiteStorageReader { + static func read(_ type: T.Type, key: String, blog: Blog) -> T? { + let scopedKey = scopedKey(key, blog: TaggedManagedObjectID(blog)) + guard let data = UserDefaults.standard.data(forKey: scopedKey) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + fileprivate static var prefix: String { "site-storage" } + fileprivate static var separator: String { "|" } + + fileprivate static func scopedKey( + _ key: String, + blog: TaggedManagedObjectID + ) -> String { + [prefix, blog.objectID.uriRepresentation().absoluteString, key] + .joined(separator: separator) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index f8c9d9ec5349..cd98a2f58a3a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -589,13 +589,17 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } + rows.append(Row.media(viewController: viewController)) + rows.append(Row.comments(viewController: viewController)) + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + for type in pinned { + rows.append(Row.pinnedPostType(type, viewController: viewController)) + } rows.append(Row.customPostTypes(viewController: viewController)) } - rows.append(Row.media(viewController: viewController)) - rows.append(Row.comments(viewController: viewController)) - let title = isSplitViewDisplayed ? nil : Strings.contentSectionTitle return Section(title: title, rows: rows, category: .content) } @@ -668,12 +672,16 @@ private extension BlogDetailsTableViewModel { rows.append(Row.pages(viewController: viewController)) } - if blog.isSelfHosted { + rows.append(Row.comments(viewController: viewController)) + + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + for type in pinned { + rows.append(Row.pinnedPostType(type, viewController: viewController)) + } rows.append(Row.customPostTypes(viewController: viewController)) } - rows.append(Row.comments(viewController: viewController)) - let title = Strings.publishSection return Section(title: title, rows: rows, category: .content) } @@ -958,6 +966,7 @@ enum BlogDetailsRowKind { case viewSite case admin case siteSettings + case pinnedPostType case removeSite } @@ -1047,14 +1056,25 @@ extension Row { static func customPostTypes(viewController: BlogDetailsViewController?) -> Row { Row( kind: .customPostTypes, - title: "Custom Post Types", - image: UIImage(systemName: "square.3.layers.3d"), + title: CustomPostTypesView.title, + image: UIImage(systemName: "ellipsis"), action: { [weak viewController] _ in viewController?.showCustomPostTypes() } ) } + static func pinnedPostType(_ type: PinnedPostType, viewController: BlogDetailsViewController?) -> Row { + Row( + kind: .pinnedPostType, + title: type.name, + image: UIImage(dashicon: type.icon), + action: { [weak viewController] _ in + viewController?.showPinnedPostType(type) + } + ) + } + static func media(viewController: BlogDetailsViewController?) -> Row { Row( kind: .media, diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 373e5d653787..80f139cc41d7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -87,30 +87,57 @@ extension BlogDetailsViewController { } public func showCustomPostTypes() { - Task { - guard let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)), - let service = await client.service - else { - return + guard let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } + + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: self) { [blog] client in + CustomPostTypesView(client: client, blog: blog) } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + presentationDelegate?.presentBlogDetailsViewController(controller) + } - let feature = NSLocalizedString( - "applicationPasswordRequired.feature.customPosts", - value: "Custom Post Types", - comment: "Feature name for managing custom post types in the app" - ) - let rootView = ApplicationPasswordRequiredView( - blog: blog, - localizedFeatureName: feature, - presentingViewController: self) { [blog] client in - CustomPostTypesView(client: client, service: service, blog: blog) - } - let controller = UIHostingController(rootView: rootView) - controller.navigationItem.largeTitleDisplayMode = .never - presentationDelegate?.presentBlogDetailsViewController(controller) + func syncPostTypes() { + guard FeatureFlag.customPostTypes.enabled, let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } + + let client = WordPressClientFactory.shared.instance(for: site) + Task { + do { + let service = try await client.service + _ = try await service.postTypes().syncPostTypes() + } catch { + DDLogError("Failed to sync post types: \(error)") + } } } + func showPinnedPostType(_ postType: PinnedPostType) { + guard let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } + + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: self) { [blog] client in + PinnedPostTypeView(client: client, blog: blog, postType: postType) + } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + presentationDelegate?.presentBlogDetailsViewController(controller) + } + public func showMediaLibrary(from source: BlogDetailsNavigationSource) { showMediaLibrary(from: source, showPicker: false) } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift index c6d4c953a9b7..130c151b5f1a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift @@ -80,6 +80,7 @@ public class BlogDetailsViewController: UIViewController { observeManagedObjectContextObjectsDidChangeNotification() observeGravatarImageUpdate() downloadGravatarImage() + syncPostTypes() registerForTraitChanges([UITraitHorizontalSizeClass.self], action: #selector(handleTraitChanges)) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 8df6d1ec16e4..7bb9c9e96355 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -8,16 +8,41 @@ import WordPressUI /// Displays a paginated list of custom posts. /// /// Used to show posts filtered by status or search results. -struct CustomPostListView: View { +struct CustomPostListView: View { @ObservedObject var viewModel: CustomPostListViewModel let details: PostTypeDetailsWithEditContext let onSelectPost: (AnyPostWithEditContext) -> Void + @ViewBuilder let header: () -> Header + + init( + viewModel: CustomPostListViewModel, + details: PostTypeDetailsWithEditContext, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void + ) where Header == EmptyView { + self.viewModel = viewModel + self.details = details + self.onSelectPost = onSelectPost + self.header = { EmptyView() } + } + + init( + viewModel: CustomPostListViewModel, + details: PostTypeDetailsWithEditContext, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void, + @ViewBuilder header: @escaping () -> Header + ) { + self.viewModel = viewModel + self.details = details + self.onSelectPost = onSelectPost + self.header = header + } var body: some View { PaginatedList( items: viewModel.items, onLoadNextPage: { try await viewModel.loadNextPage() }, - onSelectPost: onSelectPost + onSelectPost: onSelectPost, + header: header ) .overlay { if viewModel.shouldDisplayEmptyView { @@ -43,16 +68,47 @@ struct CustomPostListView: View { } } -private struct PaginatedList: View { +private struct PaginatedList: View { let items: [CustomPostCollectionItem] let onLoadNextPage: () async throws -> Void let onSelectPost: (AnyPostWithEditContext) -> Void + @ViewBuilder let header: () -> Header @State var isLoadingMore = false @State var loadMoreError: Error? + init( + items: [CustomPostCollectionItem], + onLoadNextPage: @escaping () async throws -> Void, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void + ) where Header == EmptyView { + self.items = items + self.onLoadNextPage = onLoadNextPage + self.onSelectPost = onSelectPost + self.header = { EmptyView() } + } + + init( + items: [CustomPostCollectionItem], + onLoadNextPage: @escaping () async throws -> Void, + onSelectPost: @escaping (AnyPostWithEditContext) -> Void, + @ViewBuilder header: @escaping () -> Header + ) { + self.items = items + self.onLoadNextPage = onLoadNextPage + self.onSelectPost = onSelectPost + self.header = header + } + var body: some View { List { + Section { + header() + .listRowInsets(.zero) + } + .listSectionSpacing(0) + .listSectionSeparator(.hidden) + ForEach(items) { item in ForEachContent(item: item, onSelectPost: onSelectPost) .task { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 0b4ee85e1975..7b07a665e1e6 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -7,7 +7,6 @@ import WordPressShared @MainActor final class CustomPostListViewModel: ObservableObject { - private let service: WpSelfHostedService private let client: WordPressClient private let endpoint: PostEndpointType let filter: CustomPostListFilter @@ -37,7 +36,6 @@ final class CustomPostListViewModel: ObservableObject { filter: CustomPostListFilter ) { self.client = client - self.service = service self.endpoint = endpoint self.filter = filter @@ -72,9 +70,7 @@ final class CustomPostListViewModel: ObservableObject { } func handleDataChanges() async { - guard let cache = await client.cache else { return } - - let updates = cache.databaseUpdatesPublisher() + let updates = await client.cache.databaseUpdatesPublisher() .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .values for await hook in updates { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift deleted file mode 100644 index 93307317d86e..000000000000 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostMainView.swift +++ /dev/null @@ -1,191 +0,0 @@ -import Foundation -import SwiftUI -import WordPressCore -import WordPressAPI -import WordPressAPIInternal -import WordPressApiCache -import WordPressUI -import WordPressData - -/// The top-level screen for browsing custom posts of a specific type. -/// -/// Provides search, status filtering, and navigation to the post editor. -struct CustomPostMainView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - let blog: Blog - - @State private var filter = CustomPostListFilter.default - @State private var searchText = "" - @State private var selectedPost: AnyPostWithEditContext? - @State private var filteredViewModel: CustomPostListViewModel - - init( - client: WordPressClient, - service: WpSelfHostedService, - endpoint: PostEndpointType, - details: PostTypeDetailsWithEditContext, - blog: Blog - ) { - self.client = client - self.service = service - self.endpoint = endpoint - self.details = details - self.blog = blog - - _filteredViewModel = State(initialValue: CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: .default - )) - } - - private var isFiltered: Bool { - filter.status != .custom("any") - } - - var body: some View { - ZStack { - if searchText.isEmpty { - CustomPostListView( - viewModel: filteredViewModel, - details: details, - onSelectPost: { selectedPost = $0 } - ) - } else { - CustomPostSearchResultView( - client: client, - service: service, - endpoint: endpoint, - details: details, - searchText: $searchText, - onSelectPost: { selectedPost = $0 } - ) - } - } - .onChange(of: filter) { - filteredViewModel = CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: filter - ) - } - .searchable(text: $searchText) - .fullScreenCover(item: $selectedPost) { post in - // TODO: Check if the post supports Gutenberg first? - CustomPostEditor(client: client, post: post, details: details, blog: blog) - } - .navigationTitle(details.labels.itemsList) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - filterMenu - } - } - .task { - EditorDependencyManager.shared - .prefetchDependencies( - for: blog, - postType: .init( - postType: details.slug, - restBase: details.restBase, - restNamespace: details.restNamespace - ) - ) - } - } - - private var filterMenu: some View { - Menu { - Picker("", selection: $filter.status) { - Text(Strings.filterAll).tag(PostStatus.custom("any")) - Text(Strings.filterPublished).tag(PostStatus.publish) - Text(Strings.filterDraft).tag(PostStatus.draft) - Text(Strings.filterScheduled).tag(PostStatus.future) - } - .pickerStyle(.inline) - } label: { - Image(systemName: "line.3.horizontal.decrease") - } - .foregroundStyle(isFiltered ? Color.white : .primary) - .background { - if isFiltered { - Circle() - .fill(Color.accentColor) - } - } - } -} - -private struct CustomPostSearchResultView: View { - let client: WordPressClient - let service: WpSelfHostedService - let endpoint: PostEndpointType - let details: PostTypeDetailsWithEditContext - @Binding var searchText: String - let onSelectPost: (AnyPostWithEditContext) -> Void - - @State private var finalSearchText = "" - - var body: some View { - CustomPostListView( - viewModel: CustomPostListViewModel( - client: client, - service: service, - endpoint: endpoint, - filter: CustomPostListFilter.default.with(search: finalSearchText) - ), - details: details, - onSelectPost: onSelectPost - ) - .task(id: searchText) { - do { - try await Task.sleep(for: .milliseconds(100)) - finalSearchText = searchText - } catch { - // Do nothing. - } - } - } -} - -private enum Strings { - static let sortByDateCreated = NSLocalizedString( - "postList.menu.sortByDateCreated", - value: "Sort by Date Created", - comment: "Menu item to sort posts by creation date" - ) - static let sortByDateModified = NSLocalizedString( - "postList.menu.sortByDateModified", - value: "Sort by Date Modified", - comment: "Menu item to sort posts by modification date" - ) - static let filter = NSLocalizedString( - "postList.menu.filter", - value: "Filter", - comment: "Menu item to access filter options" - ) - static let filterAll = NSLocalizedString( - "postList.menu.filter.all", - value: "All", - comment: "Filter option to show all posts" - ) - static let filterPublished = NSLocalizedString( - "postList.menu.filter.published", - value: "Published", - comment: "Filter option to show only published posts" - ) - static let filterDraft = NSLocalizedString( - "postList.menu.filter.draft", - value: "Draft", - comment: "Filter option to show only draft posts" - ) - static let filterScheduled = NSLocalizedString( - "postList.menu.filter.scheduled", - value: "Scheduled", - comment: "Filter option to show only scheduled posts" - ) -} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift new file mode 100644 index 000000000000..634466cd2860 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftUI +import WordPressAPI +import WordPressAPIInternal +import WordPressCore + +struct CustomPostSearchResultView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + @Binding var searchText: String + let onSelectPost: (AnyPostWithEditContext) -> Void + + @State private var finalSearchText = "" + + var body: some View { + CustomPostListView( + viewModel: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter.default.with(search: finalSearchText) + ), + details: details, + onSelectPost: onSelectPost + ) + .task(id: searchText) { + do { + try await Task.sleep(for: .milliseconds(100)) + finalSearchText = searchText + } catch { + // Do nothing. + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift new file mode 100644 index 000000000000..b68e3e79265b --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -0,0 +1,210 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressAPI +import WordPressAPIInternal +import WordPressApiCache +import WordPressUI +import WordPressData + +struct CustomPostTabView: View { + let client: WordPressClient + let service: WpSelfHostedService + let endpoint: PostEndpointType + let details: PostTypeDetailsWithEditContext + let blog: Blog + + @State private var selectedTab: CustomPostTab = .published + @State private var searchText = "" + @State private var publishedViewModel: CustomPostListViewModel + @State private var draftsViewModel: CustomPostListViewModel + @State private var scheduledViewModel: CustomPostListViewModel + @State private var trashViewModel: CustomPostListViewModel + @State private var selectedPost: AnyPostWithEditContext? + + private var activeViewModel: CustomPostListViewModel { + switch selectedTab { + case .published: + publishedViewModel + case .drafts: + draftsViewModel + case .scheduled: + scheduledViewModel + case .trash: + trashViewModel + } + } + + init( + client: WordPressClient, + service: WpSelfHostedService, + endpoint: PostEndpointType, + details: PostTypeDetailsWithEditContext, + blog: Blog + ) { + self.client = client + self.service = service + self.endpoint = endpoint + self.details = details + self.blog = blog + + _publishedViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .publish) + )) + _draftsViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .draft) + )) + _scheduledViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .future) + )) + _trashViewModel = State(initialValue: CustomPostListViewModel( + client: client, + service: service, + endpoint: endpoint, + filter: CustomPostListFilter(status: .trash) + )) + } + + var body: some View { + ZStack { + if searchText.isEmpty { + CustomPostListView( + viewModel: activeViewModel, + details: details, + onSelectPost: { selectedPost = $0 }, + header: { tabBar } + ) + } else { + CustomPostSearchResultView( + client: client, + service: service, + endpoint: endpoint, + details: details, + searchText: $searchText, + onSelectPost: { selectedPost = $0 } + ) + } + } + .searchable(text: $searchText) + .navigationTitle(details.name) + .fullScreenCover(item: $selectedPost) { post in + CustomPostEditor(client: client, post: post, details: details, blog: blog) + } + .task { + EditorDependencyManager.shared + .prefetchDependencies( + for: blog, + postType: .init( + postType: details.slug, + restBase: details.restBase, + restNamespace: details.restNamespace + ) + ) + } + } + + private var tabBar: some View { + AdaptiveTabBarRepresentable( + items: CustomPostTab.allCases, + selectedTab: $selectedTab + ) + .frame(height: AdaptiveTabBar.tabBarHeight) + } +} + +enum CustomPostTab: Int, CaseIterable, AdaptiveTabBarItem { + case published = 0 + case drafts + case scheduled + case trash + + var id: Self { self } + + var localizedTitle: String { + switch self { + case .published: return Strings.published + case .drafts: return Strings.drafts + case .scheduled: return Strings.scheduled + case .trash: return Strings.trash + } + } + + var status: PostStatus { + switch self { + case .published: return .publish + case .drafts: return .draft + case .scheduled: return .future + case .trash: return .trash + } + } +} + +private struct AdaptiveTabBarRepresentable: UIViewRepresentable { + let items: [CustomPostTab] + @Binding var selectedTab: CustomPostTab + + func makeUIView(context: Context) -> AdaptiveTabBar { + let tabBar = AdaptiveTabBar() + tabBar.items = items + tabBar.addTarget(context.coordinator, action: #selector(Coordinator.tabChanged(_:)), for: .valueChanged) + return tabBar + } + + func updateUIView(_ uiView: AdaptiveTabBar, context: Context) { + if let index = items.firstIndex(of: selectedTab), uiView.selectedIndex != index { + uiView.setSelectedIndex(index, animated: true) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(items: items, selectedTab: $selectedTab) + } + + class Coordinator: NSObject { + let items: [CustomPostTab] + @Binding var selectedTab: CustomPostTab + + init(items: [CustomPostTab], selectedTab: Binding) { + self.items = items + _selectedTab = selectedTab + } + + @objc func tabChanged(_ tabBar: AdaptiveTabBar) { + if items.indices.contains(tabBar.selectedIndex) { + selectedTab = items[tabBar.selectedIndex] + } + } + } +} + +private enum Strings { + static let published = NSLocalizedString( + "customPostTab.published", + value: "Published", + comment: "Tab title for published posts" + ) + static let drafts = NSLocalizedString( + "customPostTab.drafts", + value: "Drafts", + comment: "Tab title for draft posts" + ) + static let scheduled = NSLocalizedString( + "customPostTab.scheduled", + value: "Scheduled", + comment: "Tab title for scheduled posts" + ) + static let trash = NSLocalizedString( + "customPostTab.trash", + value: "Trash", + comment: "Tab title for trashed posts" + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index b2dce67db70b..f6efa8cbb294 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -7,42 +7,54 @@ import WordPressAPIInternal import WordPressUI struct CustomPostTypesView: View { + static var title: String { + Strings.title + } + let client: WordPressClient - let service: WpSelfHostedService let blog: Blog - let collection: PostTypeCollectionWithEditContext + // The following state should only be initiated once. + @State private var service: WpSelfHostedService? + @State private var collection: PostTypeCollectionWithEditContext? @State private var types: [(PostEndpointType, PostTypeDetailsWithEditContext)] = [] @State private var isLoading: Bool = true @State private var error: Error? + @State private var isEditing = false + + @SiteStorage private var pinnedTypes: [PinnedPostType] - init(client: WordPressClient, service: WpSelfHostedService, blog: Blog) { + init(client: WordPressClient, blog: Blog) { self.client = client - self.service = service self.blog = blog - self.collection = service.postTypes().createPostTypeCollectionWithEditContext() + _pinnedTypes = .pinnedPostTypes(for: blog) } var body: some View { List { ForEach(types, id: \.1.slug) { (type, details) in - NavigationLink { - CustomPostMainView(client: client, service: service, endpoint: type, details: details, blog: blog) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(details.name) - - if !details.description.isEmpty { - Text(details.description) - .foregroundColor(.secondary) - } - } + if isEditing { + editingRow(for: details) + } else { + navigationRow(for: type, details: details) } } } .listStyle(.plain) .navigationTitle(Strings.title) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + withAnimation { + isEditing.toggle() + } + } label: { + Text(isEditing ? SharedStrings.Button.done : SharedStrings.Button.edit) + } + .disabled(types.isEmpty) + } + } .overlay { if isLoading { ProgressView() @@ -54,13 +66,18 @@ struct CustomPostTypesView: View { } } .task { + await setUp() + } + .task(id: collection.flatMap(ObjectIdentifier.init)) { + guard let collection else { return } + await refresh() isLoading = self.types.isEmpty defer { isLoading = false } do { - _ = try await self.collection.fetch() + _ = try await collection.fetch() await refresh() } catch { DDLogError("Failed to query stored post types: \(error)") @@ -73,9 +90,71 @@ struct CustomPostTypesView: View { } } + private func editingRow(for details: PostTypeDetailsWithEditContext) -> some View { + let isPinned = pinnedTypes.contains { $0.slug == details.slug } + return HStack { + Image(dashicon: details.icon) + .frame(width: 36) + Text(details.name) + Spacer() + Button { + togglePin(for: details) + } label: { + Image(systemName: isPinned ? "pin.fill" : "pin") + } + .foregroundStyle(isPinned ? Color.accentColor : .secondary) + .accessibilityLabel(isPinned ? Strings.unpinButton : Strings.pinButton) + } + } + + private func navigationRow(for type: PostEndpointType, details: PostTypeDetailsWithEditContext) -> some View { + let isPinned = pinnedTypes.contains { $0.slug == details.slug } + return NavigationLink { + if let service { + CustomPostTabView(client: client, service: service, endpoint: type, details: details, blog: blog) + } + } label: { + HStack { + Image(dashicon: details.icon) + .frame(width: 36) + Text(details.name) + if isPinned { + Spacer() + Image(systemName: "pin.fill") + .foregroundStyle(Color.accentColor) + } + } + } + } + + private func togglePin(for details: PostTypeDetailsWithEditContext) { + if let index = pinnedTypes.firstIndex(where: { $0.slug == details.slug }) { + pinnedTypes.remove(at: index) + } else { + pinnedTypes.append(PinnedPostType(slug: details.slug, name: details.name, icon: details.icon)) + } + } + + private func setUp() async { + if service == nil { + do { + service = try await client.service + } catch { + self.isLoading = false + self.error = error + } + } + + if let service, collection == nil { + collection = service.postTypes().createPostTypeCollectionWithEditContext() + } + } + private func refresh() async { + guard let collection else { return } + do { - self.types = try await self.collection.loadData() + self.types = try await collection.loadData() .compactMap { let details = $0.data let endpoint = details.toPostEndpointType() @@ -97,7 +176,7 @@ struct CustomPostTypesView: View { private enum Strings { static let title = NSLocalizedString( "customPostTypes.title", - value: "Custom Post Types", + value: "More Content", comment: "Title for the Custom Post Types screen" ) @@ -106,4 +185,16 @@ private enum Strings { value: "No Custom Post Types", comment: "Empty state message when there are no custom post types to display" ) + + static let pinButton = NSLocalizedString( + "customPostTypes.pin.accessibilityLabel", + value: "Pin", + comment: "Accessibility label for the button to pin a custom post type" + ) + + static let unpinButton = NSLocalizedString( + "customPostTypes.unpin.accessibilityLabel", + value: "Unpin", + comment: "Accessibility label for the button to unpin a custom post type" + ) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift new file mode 100644 index 000000000000..e6f6bf4779d2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/Icons.swift @@ -0,0 +1,115 @@ +import SwiftUI + +extension Image { + init(dashicon: String?) { + self.init(systemName: systemName(forDashicon: dashicon)) + } +} + +extension UIImage { + convenience init?(dashicon: String?) { + self.init(systemName: systemName(forDashicon: dashicon)) + } +} + +private func systemName(forDashicon dashicon: String?) -> String { + let fallback = "doc.richtext" + guard let dashicon, dashicon.hasPrefix("dashicons-") else { + return fallback + } + + let icon = dashicon.removingPrefix("dashicons-") + return mapping[icon] ?? fallback +} + +private let mapping: [String: String] = [ + // Commerce & Products + "products": "shippingbox", + "archive": "archivebox", + "cart": "cart", + "store": "storefront", + "money": "dollarsign.circle", + "money-alt": "dollarsign.circle", + "award": "rosette", + "tickets": "ticket", + "tickets-alt": "ticket", + + // Events & Scheduling + "calendar": "calendar", + "calendar-alt": "calendar", + "clock": "clock", + "location": "mappin", + "location-alt": "mappin", + + // Creative & Portfolio + "art": "paintbrush", + "portfolio": "rectangle.stack", + "images-alt": "photo.on.rectangle.angled", + "images-alt2": "photo.on.rectangle.angled", + "format-gallery": "photo.on.rectangle.angled", + "format-image": "photo", + "format-video": "video", + "format-audio": "music.note", + "camera": "camera", + "album": "square.stack", + + // People & Business + "businessman": "person", + "businesswoman": "person", + "businessperson": "person", + "groups": "person.3", + "testimonial": "quote.bubble", + "building": "building.2", + "id": "person.text.rectangle", + "id-alt": "person.text.rectangle", + + // Education & Content + "book": "book", + "book-alt": "book", + "lightbulb": "lightbulb", + "slides": "rectangle.on.rectangle", + "clipboard": "doc.on.clipboard", + "media-document": "doc", + "text-page": "doc.text", + + // Communication + "megaphone": "megaphone", + "email": "envelope", + "email-alt": "envelope", + "phone": "phone", + "microphone": "mic", + "format-quote": "quote.bubble", + + // Miscellaneous + "food": "fork.knife", + "heart": "heart", + "star-filled": "star.fill", + "star-empty": "star", + "flag": "flag", + "tag": "tag", + "hammer": "hammer", + "car": "car", + "airplane": "airplane", + "pets": "pawprint", + "games": "gamecontroller", + "admin-home": "house", + "admin-page": "doc", +] + +#Preview { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))], spacing: 16) { + ForEach(mapping.keys.sorted(), id: \.self) { key in + VStack(spacing: 8) { + Image(systemName: mapping[key]!) + .font(.title) + Text(key) + .font(.caption) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + } + .padding() + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift new file mode 100644 index 000000000000..211ab59c8a6f --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftUI +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal +import WordPressUI + +struct PinnedPostTypeView: View { + let client: WordPressClient + let blog: Blog + let postType: PinnedPostType + + @SiteStorage private var pinnedTypes: [PinnedPostType] + + @State private var resolved: (WpSelfHostedService, PostTypeDetailsWithEditContext)? + @State private var isLoading = true + @State private var error: Error? + + init(client: WordPressClient, blog: Blog, postType: PinnedPostType) { + self.client = client + self.blog = blog + self.postType = postType + _pinnedTypes = .pinnedPostTypes(for: blog) + } + + var body: some View { + Group { + if let (service, details) = resolved { + CustomPostTabView(client: client, service: service, endpoint: details.toPostEndpointType(), details: details, blog: blog) + } else if isLoading { + ProgressView() + .progressViewStyle(.circular) + } else if let error { + EmptyStateView.failure(error: error, onRetry: error is PostTypeNotFoundError ? nil : { retry() }) + } + } + .task { + await resolve() + } + } + + private func retry() { + error = nil + isLoading = true + Task { + await resolve() + } + } + + private func resolve() async { + defer { isLoading = false } + + let slug = postType.slug + + do { + let service = try await client.service + let postTypes = service.postTypes() + + if let details = postTypes.getBySlug(slug: slug) { + resolved = (service, details) + return + } + + _ = try await postTypes.syncPostTypes() + + if let details = postTypes.getBySlug(slug: slug) { + resolved = (service, details) + } else { + pinnedTypes.removeAll { $0.slug == slug } + self.error = PostTypeNotFoundError(name: postType.name) + } + } catch { + DDLogError("Failed to resolve post type '\(slug)': \(error)") + self.error = error + } + } +} + +struct PinnedPostType: Codable, Hashable { + let slug: String + let name: String + let icon: String? +} + +extension SiteStorage where Value == [PinnedPostType] { + static func pinnedPostTypes(for blog: Blog) -> Self { + SiteStorage(wrappedValue: [], "pinned-post-types", blog: TaggedManagedObjectID(blog)) + } +} + +extension SiteStorageReader { + static func pinnedPostTypes(for blog: Blog) -> [PinnedPostType] { + read([PinnedPostType].self, key: "pinned-post-types", blog: blog) ?? [] + } +} + +private struct PostTypeNotFoundError: LocalizedError { + let name: String + + var errorDescription: String? { + String.localizedStringWithFormat(Strings.notFound, name) + } +} + +private enum Strings { + static let notFound = NSLocalizedString( + "pinnedPostType.error.notFound", + value: "\"%1$@\" is not available on this site.", + comment: "Error message when a pinned custom post type cannot be found. %1$@ is the post type name." + ) +} diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift index fe4c7bb68df8..8faca4f52aaa 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift @@ -230,7 +230,7 @@ private extension CustomPostEditorViewController { // Refresh post in the background. This ensures the post list is up-to-date with the new changes. Task { - try await client.service?.posts().refreshPost(postId: post.id, endpointType: endpoint) + try await client.service.posts().refreshPost(postId: post.id, endpointType: endpoint) } } From 8a0c9a4205853ca1682ef8b102f781487a9bac34 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Feb 2026 10:16:03 +1300 Subject: [PATCH 3/9] Show pinned post types on My Site --- .../DashboardQuickActionCell.swift | 2 + .../DashboardQuickActionsCardCell.swift | 23 ++++++++ .../DashboardQuickActionsViewModel.swift | 57 +++++++++++++++---- .../BlogDashboardPersonalizationService.swift | 2 +- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift index d945cbdc227e..b964fb5ab4c2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift @@ -28,6 +28,8 @@ final class DashboardQuickActionCell: UITableViewCell { titleLabel.adjustsFontForContentSizeCategory = true iconView.tintColor = .label + iconView.contentMode = .center + iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true let spacer = UIView() spacer.translatesAutoresizingMaskIntoConstraints = false 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 eec5ab2892bb..651790a0fa80 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 @@ -4,6 +4,7 @@ import WordPressData import WordPressShared import SwiftUI import WordPressAPI +import WordPressCore import WordPressUI final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITableViewDataSource, UITableViewDelegate { @@ -118,9 +119,31 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab viewController.presentationDelegate = self self.blogDetailsViewController = viewController self.parentViewController?.show(viewController, sender: nil) + case .postType(let postType): + showPinnedPostType(postType, blog: blog) } } + private func showPinnedPostType(_ postType: PinnedPostType, blog: Blog) { + guard let parentViewController else { return } + + let feature = NSLocalizedString( + "applicationPasswordRequired.feature.customPosts", + value: "Custom Post Types", + comment: "Feature name for managing custom post types in the app" + ) + let rootView = ApplicationPasswordRequiredView( + blog: blog, + localizedFeatureName: feature, + presentingViewController: parentViewController + ) { client in + PinnedPostTypeView(client: client, blog: blog, postType: postType) + } + let controller = UIHostingController(rootView: rootView) + controller.navigationItem.largeTitleDisplayMode = .never + parentViewController.show(controller, sender: nil) + } + private func trackQuickActionsEvent(_ event: WPAnalyticsStat, blog: Blog) { var properties: [String: Any] = [ WPAppAnalyticsKeyTabSource: "dashboard", diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift index e68b02d3b941..b6a7c78e7e3d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift @@ -1,6 +1,7 @@ import UIKit import Combine import WordPressData +import WordPressShared final class DashboardQuickActionsViewModel { var onViewWillAppear: (() -> Void)? @@ -21,18 +22,29 @@ final class DashboardQuickActionsViewModel { } @objc private func refresh() { - let items = DashboardQuickAction.allCases + var actions = DashboardQuickAction.standardActions .filter { personalizationService.isEnabled($0) && $0.isEligible(for: blog) } - .map { - DashboardQuickActionItemViewModel( - image: $0.image, - title: $0.localizedTitle, - action: $0 - ) + + if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { + let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + .map { DashboardQuickAction.postType($0) } + if let moreIndex = actions.firstIndex(of: .more) { + actions.insert(contentsOf: pinned, at: moreIndex) + } else { + actions.append(contentsOf: pinned) } + } + + let items = actions.map { + DashboardQuickActionItemViewModel( + image: $0.image, + title: $0.localizedTitle, + action: $0 + ) + } if self.items != items { self.items = items @@ -41,6 +53,7 @@ final class DashboardQuickActionsViewModel { func viewWillAppear() { onViewWillAppear?() + refresh() } func viewWillDisappear() { @@ -55,13 +68,30 @@ struct DashboardQuickActionItemViewModel: Hashable { let action: DashboardQuickAction } -enum DashboardQuickAction: String, CaseIterable { +enum DashboardQuickAction: Hashable { case stats case posts case pages case media case comments case more + case postType(PinnedPostType) + + static let standardActions: [DashboardQuickAction] = [ + .stats, .posts, .pages, .media, .comments, .more + ] + + var settingsKey: String { + switch self { + case .stats: return "stats" + case .posts: return "posts" + case .pages: return "pages" + case .media: return "media" + case .comments: return "comments" + case .more: return "more" + case .postType(let type): return "postType-\(type.slug)" + } + } var localizedTitle: String { switch self { @@ -77,6 +107,8 @@ enum DashboardQuickAction: String, CaseIterable { return NSLocalizedString("dashboard.menu.stats", value: "Stats", comment: "Title for stats button on dashboard.") case .more: return NSLocalizedString("dashboard.menu.more", value: "More", comment: "Title for more button on dashboard.") + case .postType(let type): + return type.name } } @@ -94,6 +126,8 @@ enum DashboardQuickAction: String, CaseIterable { return UIImage(named: "site-menu-stats") case .more: return UIImage(named: "site-menu-more") + case .postType(let type): + return UIImage(dashicon: type.icon) } } @@ -105,12 +139,13 @@ enum DashboardQuickAction: String, CaseIterable { case .media: "quick_actions_media" case .comments: "quick_actions_comments" case .more: "quick_actions_more" + case .postType(let type): "quick_actions_post_type_\(type.slug)" } } var isEnabledByDefault: Bool { switch self { - case .posts, .pages, .media, .stats, .more: + case .posts, .pages, .media, .stats, .more, .postType: return true case .comments: return false @@ -123,13 +158,13 @@ enum DashboardQuickAction: String, CaseIterable { return blog.supports(.pages) case .stats: return blog.supports(.stats) - case .posts, .comments, .media, .more: + case .posts, .comments, .media, .more, .postType: return true } } static let personalizableActions: [DashboardQuickAction] = { - var actions = DashboardQuickAction.allCases + var actions = standardActions actions.removeAll(where: { $0 == .more }) return actions }() diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift index f9750c61380b..b66b8a158a77 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift @@ -136,7 +136,7 @@ struct BlogDashboardPersonalizationService { } private func makeKey(for action: DashboardQuickAction) -> String { - "quick-action-\(action.rawValue)-hidden" + "quick-action-\(action.settingsKey)-hidden" } extension NSNotification.Name { From 4995246fb153261a3a34ab91b03ec6438fd8b21a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Feb 2026 20:31:57 +1300 Subject: [PATCH 4/9] Automatically pin post types --- Modules/Sources/WordPressCore/ApiCache.swift | 21 +++-- WordPress/Classes/Utility/SiteStorage.swift | 19 ++++- .../DashboardQuickActionsCardCell.swift | 2 +- .../DashboardQuickActionsViewModel.swift | 15 +++- .../ViewModel/BlogDashboardViewModel.swift | 1 + .../BlogDetailsTableViewModel.swift | 14 ++- .../BlogDetailsViewController+Swift.swift | 22 ++--- .../CustomPostTypeService.swift | 85 +++++++++++++++++++ .../CustomPostTypes/CustomPostTypesView.swift | 79 ++++------------- .../CustomPostTypes/PinnedPostTypeView.swift | 52 ++++++------ 10 files changed, 193 insertions(+), 117 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift diff --git a/Modules/Sources/WordPressCore/ApiCache.swift b/Modules/Sources/WordPressCore/ApiCache.swift index c82e6f85eb26..e3425c42d226 100644 --- a/Modules/Sources/WordPressCore/ApiCache.swift +++ b/Modules/Sources/WordPressCore/ApiCache.swift @@ -10,16 +10,10 @@ extension WordPressApiCache { return instance } + // TODO: + // - Log errors to sentry: https://github.com/wordpress-mobile/WordPress-iOS/pull/25157#discussion_r2785458461 private static func onDiskCache() -> WordPressApiCache? { - let cacheURL: URL - do { - cacheURL = try FileManager.default - .url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - .appending(path: "app.sqlite") - } catch { - NSLog("Failed to create api cache file: \(error)") - return nil - } + let cacheURL = URL.libraryDirectory.appending(path: "app.sqlite") if let cache = WordPressApiCache.onDiskCache(file: cacheURL) { return cache @@ -56,6 +50,15 @@ extension WordPressApiCache { return nil } + do { + var url = file + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) + } catch { + NSLog("Failed exclude the database file from iCloud backup: \(error)") + } + return cache } diff --git a/WordPress/Classes/Utility/SiteStorage.swift b/WordPress/Classes/Utility/SiteStorage.swift index 038d0485f1e8..5e63c71be7c7 100644 --- a/WordPress/Classes/Utility/SiteStorage.swift +++ b/WordPress/Classes/Utility/SiteStorage.swift @@ -22,18 +22,29 @@ struct SiteStorage: DynamicProperty { init(wrappedValue: Value, _ key: String, blog: TaggedManagedObjectID, store: UserDefaults? = nil) { self.defaultValue = wrappedValue - let scopedKey = SiteStorageReader.scopedKey(key, blog: blog) + let scopedKey = SiteStorageAccess.scopedKey(key, blog: blog) _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) } } -enum SiteStorageReader { - static func read(_ type: T.Type, key: String, blog: Blog) -> T? { - let scopedKey = scopedKey(key, blog: TaggedManagedObjectID(blog)) +enum SiteStorageAccess { + static func read(_ type: T.Type, key: String, blog: TaggedManagedObjectID) -> T? { + let scopedKey = scopedKey(key, blog: blog) guard let data = UserDefaults.standard.data(forKey: scopedKey) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } + static func write(_ value: T, key: String, blog: TaggedManagedObjectID) { + let scopedKey = scopedKey(key, blog: blog) + let data = (try? JSONEncoder().encode(value)) ?? Data() + UserDefaults.standard.set(data, forKey: scopedKey) + } + + static func exists(key: String, blog: TaggedManagedObjectID) -> Bool { + let scopedKey = scopedKey(key, blog: blog) + return UserDefaults.standard.object(forKey: scopedKey) != nil + } + fileprivate static var prefix: String { "site-storage" } fileprivate static var separator: String { "|" } 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 651790a0fa80..6e5866573010 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 localizedFeatureName: feature, presentingViewController: parentViewController ) { client in - PinnedPostTypeView(client: client, blog: blog, postType: postType) + PinnedPostTypeView(blog: blog, service: CustomPostTypeService(client: client, blog: blog), postType: postType) } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift index b6a7c78e7e3d..0f769d34e608 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift @@ -29,7 +29,7 @@ final class DashboardQuickActionsViewModel { } if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { - let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) .map { DashboardQuickAction.postType($0) } if let moreIndex = actions.firstIndex(of: .more) { actions.insert(contentsOf: pinned, at: moreIndex) @@ -51,6 +51,19 @@ final class DashboardQuickActionsViewModel { } } + func syncCustomPostTypes() { + guard let service = CustomPostTypeService(blog: blog) else { return } + Task { @MainActor [weak self] in + do { + try await service.refresh() + + self?.refresh() + } catch { + DDLogError("Failed to refresh custom post types: \(error)") + } + } + } + func viewWillAppear() { onViewWillAppear?() refresh() diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift index 9517e269055e..fd7424e0c6a5 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -173,6 +173,7 @@ final class BlogDashboardViewModel { }) blazeViewModel.refresh() + quickActionsViewModel.syncCustomPostTypes() } @objc func loadCardsFromCache() { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index cd98a2f58a3a..938ef52c5f2f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -54,6 +54,8 @@ private struct Section { } } + var showMorePostTypes = false + var useSiteMenuStyle = false @objc public init(blog: Blog, viewController: BlogDetailsViewController) { @@ -593,11 +595,13 @@ private extension BlogDetailsTableViewModel { rows.append(Row.comments(viewController: viewController)) if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { - let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) for type in pinned { rows.append(Row.pinnedPostType(type, viewController: viewController)) } - rows.append(Row.customPostTypes(viewController: viewController)) + if showMorePostTypes { + rows.append(Row.customPostTypes(viewController: viewController)) + } } let title = isSplitViewDisplayed ? nil : Strings.contentSectionTitle @@ -675,11 +679,13 @@ private extension BlogDetailsTableViewModel { rows.append(Row.comments(viewController: viewController)) if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { - let pinned = SiteStorageReader.pinnedPostTypes(for: blog) + let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) for type in pinned { rows.append(Row.pinnedPostType(type, viewController: viewController)) } - rows.append(Row.customPostTypes(viewController: viewController)) + if showMorePostTypes { + rows.append(Row.customPostTypes(viewController: viewController)) + } } let title = Strings.publishSection diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 80f139cc41d7..5402c74e9879 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -87,8 +87,6 @@ extension BlogDetailsViewController { } public func showCustomPostTypes() { - guard let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } - let feature = NSLocalizedString( "applicationPasswordRequired.feature.customPosts", value: "Custom Post Types", @@ -98,7 +96,7 @@ extension BlogDetailsViewController { blog: blog, localizedFeatureName: feature, presentingViewController: self) { [blog] client in - CustomPostTypesView(client: client, blog: blog) + CustomPostTypesView(blog: blog, service: CustomPostTypeService(client: client, blog: blog)) } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never @@ -106,13 +104,17 @@ extension BlogDetailsViewController { } func syncPostTypes() { - guard FeatureFlag.customPostTypes.enabled, let site = try? WordPressSite(blog: blog), case .selfHosted = site else { return } - - let client = WordPressClientFactory.shared.instance(for: site) - Task { + guard let service = CustomPostTypeService(blog: blog) else { return } + Task { @MainActor [weak self] in do { - let service = try await client.service - _ = try await service.postTypes().syncPostTypes() + try await service.refresh() + + if let self { + let pinnedCount = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)).count + tableViewModel?.showMorePostTypes = try await service.customTypes().count > pinnedCount + configureTableViewData() + reloadTableViewPreservingSelection() + } } catch { DDLogError("Failed to sync post types: \(error)") } @@ -131,7 +133,7 @@ extension BlogDetailsViewController { blog: blog, localizedFeatureName: feature, presentingViewController: self) { [blog] client in - PinnedPostTypeView(client: client, blog: blog, postType: postType) + PinnedPostTypeView(blog: blog, service: CustomPostTypeService(client: client, blog: blog), postType: postType) } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift new file mode 100644 index 000000000000..804acfee15ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypeService.swift @@ -0,0 +1,85 @@ +import Foundation +import WordPressCore +import WordPressData +import WordPressAPI +import WordPressAPIInternal + +class CustomPostTypeService { + let blog: TaggedManagedObjectID + let client: WordPressClient + + private(set) var wpService: WpSelfHostedService? + private var collection: PostTypeCollectionWithEditContext? + + init(client: WordPressClient, blog: Blog) { + self.client = client + self.blog = TaggedManagedObjectID(blog) + } + + init?(blog: Blog) { + guard FeatureFlag.customPostTypes.enabled, + let site = try? WordPressSite(blog: blog), + case .selfHosted = site else { return nil } + self.blog = TaggedManagedObjectID(blog) + self.client = WordPressClientFactory.shared.instance(for: site) + } + + func refresh() async throws { + let service = try await resolveService() + _ = try await service.postTypes().syncPostTypes() + + // If the user has not manually pinned any post types (typically right after first fetching post types), + // we automatically pin 3 post types, so that the user can see their content straight from the top-level screens. + if !SiteStorageAccess.pinnedPostTypesUpdated(for: blog) { + let pinned = try await customTypes() + .prefix(3) + .map { PinnedPostType(slug: $0.slug, name: $0.name, icon: $0.icon) } + SiteStorageAccess.writePinnedPostTypes(pinned, for: blog) + } + } + + func customTypes() async throws -> [PostTypeDetailsWithEditContext] { + let collection = try await resolveCollection() + return try await collection.loadData() + .compactMap { entry -> PostTypeDetailsWithEditContext? in + let details = entry.data + if case .custom = details.toPostEndpointType(), details.slug != "attachment" { + return details + } + return nil + } + .sorted { $0.slug < $1.slug } + } + + func resolvePostType(slug: String) async throws -> PostTypeDetailsWithEditContext? { + let service = try await resolveService() + let postTypes = service.postTypes() + + if let details = postTypes.getBySlug(slug: slug) { + return details + } + + _ = try await postTypes.syncPostTypes() + + return postTypes.getBySlug(slug: slug) + } + + private func resolveService() async throws -> WpSelfHostedService { + if let wpService { + return wpService + } + let service = try await client.service + self.wpService = service + return service + } + + private func resolveCollection() async throws -> PostTypeCollectionWithEditContext { + if let collection { + return collection + } + let service = try await resolveService() + let collection = service.postTypes().createPostTypeCollectionWithEditContext() + self.collection = collection + return collection + } +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index f6efa8cbb294..9602e46ae778 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -11,33 +11,29 @@ struct CustomPostTypesView: View { Strings.title } - let client: WordPressClient let blog: Blog + let service: CustomPostTypeService - // The following state should only be initiated once. - @State private var service: WpSelfHostedService? - @State private var collection: PostTypeCollectionWithEditContext? - - @State private var types: [(PostEndpointType, PostTypeDetailsWithEditContext)] = [] + @State private var types: [PostTypeDetailsWithEditContext] = [] @State private var isLoading: Bool = true @State private var error: Error? @State private var isEditing = false @SiteStorage private var pinnedTypes: [PinnedPostType] - init(client: WordPressClient, blog: Blog) { - self.client = client + init(blog: Blog, service: CustomPostTypeService) { self.blog = blog - _pinnedTypes = .pinnedPostTypes(for: blog) + self.service = service + _pinnedTypes = .pinnedPostTypes(for: service.blog) } var body: some View { List { - ForEach(types, id: \.1.slug) { (type, details) in + ForEach(types, id: \.slug) { details in if isEditing { editingRow(for: details) } else { - navigationRow(for: type, details: details) + navigationRow(for: details) } } } @@ -66,21 +62,19 @@ struct CustomPostTypesView: View { } } .task { - await setUp() - } - .task(id: collection.flatMap(ObjectIdentifier.init)) { - guard let collection else { return } - - await refresh() + do { + types = try await service.customTypes() + } catch { + DDLogError("Failed to load cached post types: \(error)") + } - isLoading = self.types.isEmpty + isLoading = types.isEmpty defer { isLoading = false } do { - _ = try await collection.fetch() - await refresh() + try await service.refresh() + types = try await service.customTypes() } catch { - DDLogError("Failed to query stored post types: \(error)") if types.isEmpty { self.error = error } else { @@ -107,11 +101,11 @@ struct CustomPostTypesView: View { } } - private func navigationRow(for type: PostEndpointType, details: PostTypeDetailsWithEditContext) -> some View { + private func navigationRow(for details: PostTypeDetailsWithEditContext) -> some View { let isPinned = pinnedTypes.contains { $0.slug == details.slug } return NavigationLink { - if let service { - CustomPostTabView(client: client, service: service, endpoint: type, details: details, blog: blog) + if let wpService = service.wpService { + CustomPostTabView(client: service.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) } } label: { HStack { @@ -134,43 +128,6 @@ struct CustomPostTypesView: View { pinnedTypes.append(PinnedPostType(slug: details.slug, name: details.name, icon: details.icon)) } } - - private func setUp() async { - if service == nil { - do { - service = try await client.service - } catch { - self.isLoading = false - self.error = error - } - } - - if let service, collection == nil { - collection = service.postTypes().createPostTypeCollectionWithEditContext() - } - } - - private func refresh() async { - guard let collection else { return } - - do { - self.types = try await collection.loadData() - .compactMap { - let details = $0.data - let endpoint = details.toPostEndpointType() - if case .custom = endpoint, details.slug != "attachment" { - return (endpoint, details) - } - return nil - } - .sorted { - $0.1.slug < $1.1.slug - } - } catch { - DDLogError("Failed to fetch post types: \(error)") - self.error = error - } - } } private enum Strings { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift index 211ab59c8a6f..4ec9a85028b7 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift @@ -7,27 +7,28 @@ import WordPressAPIInternal import WordPressUI struct PinnedPostTypeView: View { - let client: WordPressClient let blog: Blog + let customPostTypeService: CustomPostTypeService let postType: PinnedPostType @SiteStorage private var pinnedTypes: [PinnedPostType] - @State private var resolved: (WpSelfHostedService, PostTypeDetailsWithEditContext)? + @State private var service: WpSelfHostedService? + @State private var details: PostTypeDetailsWithEditContext? @State private var isLoading = true @State private var error: Error? - init(client: WordPressClient, blog: Blog, postType: PinnedPostType) { - self.client = client + init(blog: Blog, service: CustomPostTypeService, postType: PinnedPostType) { self.blog = blog + self.customPostTypeService = service self.postType = postType - _pinnedTypes = .pinnedPostTypes(for: blog) + _pinnedTypes = .pinnedPostTypes(for: TaggedManagedObjectID(blog)) } var body: some View { Group { - if let (service, details) = resolved { - CustomPostTabView(client: client, service: service, endpoint: details.toPostEndpointType(), details: details, blog: blog) + if let details, let wpService = customPostTypeService.wpService { + CustomPostTabView(client: customPostTypeService.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) } else if isLoading { ProgressView() .progressViewStyle(.circular) @@ -50,28 +51,17 @@ struct PinnedPostTypeView: View { private func resolve() async { defer { isLoading = false } - - let slug = postType.slug - do { - let service = try await client.service - let postTypes = service.postTypes() - - if let details = postTypes.getBySlug(slug: slug) { - resolved = (service, details) - return - } - - _ = try await postTypes.syncPostTypes() + service = try await customPostTypeService.client.service - if let details = postTypes.getBySlug(slug: slug) { - resolved = (service, details) + if let details = try await customPostTypeService.resolvePostType(slug: postType.slug) { + self.details = details } else { - pinnedTypes.removeAll { $0.slug == slug } + pinnedTypes.removeAll { $0.slug == postType.slug } self.error = PostTypeNotFoundError(name: postType.name) } } catch { - DDLogError("Failed to resolve post type '\(slug)': \(error)") + DDLogError("Failed to resolve post type '\(postType.slug)': \(error)") self.error = error } } @@ -84,15 +74,23 @@ struct PinnedPostType: Codable, Hashable { } extension SiteStorage where Value == [PinnedPostType] { - static func pinnedPostTypes(for blog: Blog) -> Self { - SiteStorage(wrappedValue: [], "pinned-post-types", blog: TaggedManagedObjectID(blog)) + static func pinnedPostTypes(for blog: TaggedManagedObjectID) -> Self { + SiteStorage(wrappedValue: [], "pinned-post-types", blog: blog) } } -extension SiteStorageReader { - static func pinnedPostTypes(for blog: Blog) -> [PinnedPostType] { +extension SiteStorageAccess { + static func pinnedPostTypes(for blog: TaggedManagedObjectID) -> [PinnedPostType] { read([PinnedPostType].self, key: "pinned-post-types", blog: blog) ?? [] } + + static func writePinnedPostTypes(_ value: [PinnedPostType], for blog: TaggedManagedObjectID) { + write(value, key: "pinned-post-types", blog: blog) + } + + static func pinnedPostTypesUpdated(for blog: TaggedManagedObjectID) -> Bool { + exists(key: "pinned-post-types", blog: blog) + } } private struct PostTypeNotFoundError: LocalizedError { From dfeb69e4841c5d01bc58310d8032c61563e83088 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Feb 2026 20:56:30 +1300 Subject: [PATCH 5/9] Change "More Content" to "More" --- .../ViewRelated/CustomPostTypes/CustomPostTypesView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index 9602e46ae778..09ca6766a2b5 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -133,7 +133,7 @@ struct CustomPostTypesView: View { private enum Strings { static let title = NSLocalizedString( "customPostTypes.title", - value: "More Content", + value: "More", comment: "Title for the Custom Post Types screen" ) From 0f415f141d861dcc59b2365440b198db5a2ea71c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 11 Feb 2026 13:37:28 +1300 Subject: [PATCH 6/9] Add "Send Feedback" button to the post list --- .../CustomPostTypes/CustomPostTabView.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift index b68e3e79265b..c620e3a836cd 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -21,6 +21,7 @@ struct CustomPostTabView: View { @State private var scheduledViewModel: CustomPostListViewModel @State private var trashViewModel: CustomPostListViewModel @State private var selectedPost: AnyPostWithEditContext? + @State private var isShowingFeedback = false private var activeViewModel: CustomPostListViewModel { switch selectedTab { @@ -96,6 +97,20 @@ struct CustomPostTabView: View { } .searchable(text: $searchText) .navigationTitle(details.name) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(action: { isShowingFeedback = true }) { + Label(Strings.sendFeedback, systemImage: "envelope") + } + } label: { + Image(systemName: "ellipsis") + } + } + } + .sheet(isPresented: $isShowingFeedback) { + SubmitFeedbackViewRepresentable() + } .fullScreenCover(item: $selectedPost) { post in CustomPostEditor(client: client, post: post, details: details, blog: blog) } @@ -186,6 +201,14 @@ private struct AdaptiveTabBarRepresentable: UIViewRepresentable { } } +private struct SubmitFeedbackViewRepresentable: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> SubmitFeedbackViewController { + SubmitFeedbackViewController(source: "custom_post_types", feedbackPrefix: "CustomPostTypes") + } + + func updateUIViewController(_ uiViewController: SubmitFeedbackViewController, context: Context) {} +} + private enum Strings { static let published = NSLocalizedString( "customPostTab.published", @@ -207,4 +230,9 @@ private enum Strings { value: "Trash", comment: "Tab title for trashed posts" ) + static let sendFeedback = NSLocalizedString( + "customPostTab.sendFeedback", + value: "Send Feedback", + comment: "Menu item title for sending feedback on the custom post types screen" + ) } From 4e3066d1a718bbc08bc532c22404f61f183cf934 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 13 Feb 2026 12:31:20 +1300 Subject: [PATCH 7/9] Add a Preview block to test SiteStorage --- WordPress/Classes/Utility/SiteStorage.swift | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/WordPress/Classes/Utility/SiteStorage.swift b/WordPress/Classes/Utility/SiteStorage.swift index 5e63c71be7c7..af71549a3b8d 100644 --- a/WordPress/Classes/Utility/SiteStorage.swift +++ b/WordPress/Classes/Utility/SiteStorage.swift @@ -25,6 +25,13 @@ struct SiteStorage: DynamicProperty { let scopedKey = SiteStorageAccess.scopedKey(key, blog: blog) _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) } + + fileprivate init(wrappedValue: Value, _ key: String, scope: String, + store: UserDefaults? = nil) { + self.defaultValue = wrappedValue + let scopedKey = SiteStorageAccess.scopedKey(key, scope: scope) + _data = AppStorage(wrappedValue: Data(), scopedKey, store: store) + } } enum SiteStorageAccess { @@ -55,4 +62,38 @@ enum SiteStorageAccess { [prefix, blog.objectID.uriRepresentation().absoluteString, key] .joined(separator: separator) } + + fileprivate static func scopedKey( + _ key: String, + scope: String + ) -> String { + [prefix, scope, key] + .joined(separator: separator) + } } + +#if DEBUG + +private struct SiteStoragePreviewContent: View { + @SiteStorage("counter", scope: "tests") private var counter = 0 + + var body: some View { + VStack(spacing: 20) { + Text("Counter: \(counter)") + .font(.headline) + Button("Increment") { + let key = SiteStorageAccess.scopedKey("counter", scope: "tests") + + let newValue = counter + 1 + let encoded = (try? JSONEncoder().encode(newValue)) ?? Data() + UserDefaults.standard.set(encoded, forKey: key) + } + } + } +} + +#Preview { + SiteStoragePreviewContent() +} + +#endif From 71f572e3331d52fb8d7426401f9167a5e7da5a97 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 13 Feb 2026 21:33:57 +1300 Subject: [PATCH 8/9] Do not show post types on the Blog Dashboard --- .../DashboardQuickActionCell.swift | 2 - .../DashboardQuickActionsCardCell.swift | 23 ------ .../DashboardQuickActionsViewModel.swift | 70 +++---------------- .../BlogDashboardPersonalizationService.swift | 2 +- .../ViewModel/BlogDashboardViewModel.swift | 1 - 5 files changed, 12 insertions(+), 86 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift index b964fb5ab4c2..d945cbdc227e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionCell.swift @@ -28,8 +28,6 @@ final class DashboardQuickActionCell: UITableViewCell { titleLabel.adjustsFontForContentSizeCategory = true iconView.tintColor = .label - iconView.contentMode = .center - iconView.widthAnchor.constraint(equalToConstant: 24).isActive = true let spacer = UIView() spacer.translatesAutoresizingMaskIntoConstraints = false 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 6e5866573010..eec5ab2892bb 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 @@ -4,7 +4,6 @@ import WordPressData import WordPressShared import SwiftUI import WordPressAPI -import WordPressCore import WordPressUI final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITableViewDataSource, UITableViewDelegate { @@ -119,31 +118,9 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab viewController.presentationDelegate = self self.blogDetailsViewController = viewController self.parentViewController?.show(viewController, sender: nil) - case .postType(let postType): - showPinnedPostType(postType, blog: blog) } } - private func showPinnedPostType(_ postType: PinnedPostType, blog: Blog) { - guard let parentViewController else { return } - - let feature = NSLocalizedString( - "applicationPasswordRequired.feature.customPosts", - value: "Custom Post Types", - comment: "Feature name for managing custom post types in the app" - ) - let rootView = ApplicationPasswordRequiredView( - blog: blog, - localizedFeatureName: feature, - presentingViewController: parentViewController - ) { client in - PinnedPostTypeView(blog: blog, service: CustomPostTypeService(client: client, blog: blog), postType: postType) - } - let controller = UIHostingController(rootView: rootView) - controller.navigationItem.largeTitleDisplayMode = .never - parentViewController.show(controller, sender: nil) - } - private func trackQuickActionsEvent(_ event: WPAnalyticsStat, blog: Blog) { var properties: [String: Any] = [ WPAppAnalyticsKeyTabSource: "dashboard", diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift index 0f769d34e608..e68b02d3b941 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsViewModel.swift @@ -1,7 +1,6 @@ import UIKit import Combine import WordPressData -import WordPressShared final class DashboardQuickActionsViewModel { var onViewWillAppear: (() -> Void)? @@ -22,51 +21,26 @@ final class DashboardQuickActionsViewModel { } @objc private func refresh() { - var actions = DashboardQuickAction.standardActions + let items = DashboardQuickAction.allCases .filter { personalizationService.isEnabled($0) && $0.isEligible(for: blog) } - - if FeatureFlag.customPostTypes.enabled && blog.supportsCoreRESTAPI { - let pinned = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)) - .map { DashboardQuickAction.postType($0) } - if let moreIndex = actions.firstIndex(of: .more) { - actions.insert(contentsOf: pinned, at: moreIndex) - } else { - actions.append(contentsOf: pinned) + .map { + DashboardQuickActionItemViewModel( + image: $0.image, + title: $0.localizedTitle, + action: $0 + ) } - } - - let items = actions.map { - DashboardQuickActionItemViewModel( - image: $0.image, - title: $0.localizedTitle, - action: $0 - ) - } if self.items != items { self.items = items } } - func syncCustomPostTypes() { - guard let service = CustomPostTypeService(blog: blog) else { return } - Task { @MainActor [weak self] in - do { - try await service.refresh() - - self?.refresh() - } catch { - DDLogError("Failed to refresh custom post types: \(error)") - } - } - } - func viewWillAppear() { onViewWillAppear?() - refresh() } func viewWillDisappear() { @@ -81,30 +55,13 @@ struct DashboardQuickActionItemViewModel: Hashable { let action: DashboardQuickAction } -enum DashboardQuickAction: Hashable { +enum DashboardQuickAction: String, CaseIterable { case stats case posts case pages case media case comments case more - case postType(PinnedPostType) - - static let standardActions: [DashboardQuickAction] = [ - .stats, .posts, .pages, .media, .comments, .more - ] - - var settingsKey: String { - switch self { - case .stats: return "stats" - case .posts: return "posts" - case .pages: return "pages" - case .media: return "media" - case .comments: return "comments" - case .more: return "more" - case .postType(let type): return "postType-\(type.slug)" - } - } var localizedTitle: String { switch self { @@ -120,8 +77,6 @@ enum DashboardQuickAction: Hashable { return NSLocalizedString("dashboard.menu.stats", value: "Stats", comment: "Title for stats button on dashboard.") case .more: return NSLocalizedString("dashboard.menu.more", value: "More", comment: "Title for more button on dashboard.") - case .postType(let type): - return type.name } } @@ -139,8 +94,6 @@ enum DashboardQuickAction: Hashable { return UIImage(named: "site-menu-stats") case .more: return UIImage(named: "site-menu-more") - case .postType(let type): - return UIImage(dashicon: type.icon) } } @@ -152,13 +105,12 @@ enum DashboardQuickAction: Hashable { case .media: "quick_actions_media" case .comments: "quick_actions_comments" case .more: "quick_actions_more" - case .postType(let type): "quick_actions_post_type_\(type.slug)" } } var isEnabledByDefault: Bool { switch self { - case .posts, .pages, .media, .stats, .more, .postType: + case .posts, .pages, .media, .stats, .more: return true case .comments: return false @@ -171,13 +123,13 @@ enum DashboardQuickAction: Hashable { return blog.supports(.pages) case .stats: return blog.supports(.stats) - case .posts, .comments, .media, .more, .postType: + case .posts, .comments, .media, .more: return true } } static let personalizableActions: [DashboardQuickAction] = { - var actions = standardActions + var actions = DashboardQuickAction.allCases actions.removeAll(where: { $0 == .more }) return actions }() diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift index b66b8a158a77..f9750c61380b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift @@ -136,7 +136,7 @@ struct BlogDashboardPersonalizationService { } private func makeKey(for action: DashboardQuickAction) -> String { - "quick-action-\(action.settingsKey)-hidden" + "quick-action-\(action.rawValue)-hidden" } extension NSNotification.Name { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift index fd7424e0c6a5..9517e269055e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -173,7 +173,6 @@ final class BlogDashboardViewModel { }) blazeViewModel.refresh() - quickActionsViewModel.syncCustomPostTypes() } @objc func loadCardsFromCache() { From a3ba5d2ac59f59ef94a13af70bee5564b029aad6 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 13 Feb 2026 21:36:29 +1300 Subject: [PATCH 9/9] Always show the viewing all custom post types list Otherwise the user won't be able to unpin post types. --- .../Blog/Blog Details/BlogDetailsTableViewModel.swift | 10 ++-------- .../Blog Details/BlogDetailsViewController+Swift.swift | 2 -- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift index 938ef52c5f2f..c57ba48e268f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift @@ -54,8 +54,6 @@ private struct Section { } } - var showMorePostTypes = false - var useSiteMenuStyle = false @objc public init(blog: Blog, viewController: BlogDetailsViewController) { @@ -599,9 +597,7 @@ private extension BlogDetailsTableViewModel { for type in pinned { rows.append(Row.pinnedPostType(type, viewController: viewController)) } - if showMorePostTypes { - rows.append(Row.customPostTypes(viewController: viewController)) - } + rows.append(Row.customPostTypes(viewController: viewController)) } let title = isSplitViewDisplayed ? nil : Strings.contentSectionTitle @@ -683,9 +679,7 @@ private extension BlogDetailsTableViewModel { for type in pinned { rows.append(Row.pinnedPostType(type, viewController: viewController)) } - if showMorePostTypes { - rows.append(Row.customPostTypes(viewController: viewController)) - } + rows.append(Row.customPostTypes(viewController: viewController)) } let title = Strings.publishSection diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 5402c74e9879..5c4cc94ab79a 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -110,8 +110,6 @@ extension BlogDetailsViewController { try await service.refresh() if let self { - let pinnedCount = SiteStorageAccess.pinnedPostTypes(for: TaggedManagedObjectID(blog)).count - tableViewModel?.showMorePostTypes = try await service.customTypes().count > pinnedCount configureTableViewData() reloadTableViewPreservingSelection() }