From 665259081f07511d54410718a411f69b7f45b3fc Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 5 Mar 2026 13:59:25 +1300 Subject: [PATCH 1/8] Ellipsis menu on post list --- .../Extensions/ProgressHUDModifier.swift | 87 ++++ .../Blaze/Webview/BlazeFlowCoordinator.swift | 14 +- .../CustomPostTypes/CustomPostListView.swift | 382 +++++++++++------- .../CustomPostListViewModel.swift | 200 ++++++++- .../CustomPostSearchResultView.swift | 3 +- .../CustomPostTypes/CustomPostTabView.swift | 14 +- .../CustomPostTypes/CustomPostTypesView.swift | 2 +- .../CustomPostTypes/PinnedPostTypeView.swift | 2 +- .../PostSettings/PostSettingsViewModel.swift | 3 +- 9 files changed, 525 insertions(+), 182 deletions(-) create mode 100644 WordPress/Classes/Extensions/ProgressHUDModifier.swift diff --git a/WordPress/Classes/Extensions/ProgressHUDModifier.swift b/WordPress/Classes/Extensions/ProgressHUDModifier.swift new file mode 100644 index 000000000000..b0677e6466c4 --- /dev/null +++ b/WordPress/Classes/Extensions/ProgressHUDModifier.swift @@ -0,0 +1,87 @@ +import SVProgressHUD +import SwiftUI + +enum ProgressHUDState: Equatable { + case idle + case running + case success + case failure(String) +} + +private struct ProgressHUDModifier: ViewModifier { + @Binding var state: ProgressHUDState + @State private var dismissTask: Task? + + func body(content: Content) -> some View { + content + .onChange(of: state) { _, newValue in + dismissTask?.cancel() + dismissTask = nil + + switch newValue { + case .idle: + break + case .running: + SVProgressHUD.show() + case .success: + SVProgressHUD.showSuccess(withStatus: nil) + dismissAndReset() + case .failure(let message): + SVProgressHUD.showError(withStatus: message) + dismissAndReset() + } + } + } + + private func dismissAndReset() { + dismissTask = Task { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + await SVProgressHUD.dismiss() + state = .idle + } + } +} + +extension View { + func progressHUD(state: Binding) -> some View { + modifier(ProgressHUDModifier(state: state)) + } +} + +// MARK: - Preview + +#Preview("ProgressHUD Race Condition") { + @Previewable @State var state: ProgressHUDState = .idle + + VStack(spacing: 20) { + // Expected: .idle → .running → .success → .idle + Button("Run & Succeed") { + state = .running + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + state = .success + } + } + + // Expected: .idle → .running → .failure → .idle + Button("Run & Fail") { + state = .running + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + state = .failure("Something went wrong") + } + } + + // Expected: .idle → .success → .running (spinner stays on screen) + Button("Quick Succession (auto)") { + state = .success + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + state = .running + } + } + + Text("State: \(String(describing: state))") + .font(.headline) + .animation(.none, value: state) + } + .progressHUD(state: $state) +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift index dcf12f4775d5..b16abb697705 100644 --- a/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift @@ -83,6 +83,18 @@ import WordPressData blog: Blog, postID: NSNumber? = nil, delegate: BlazeWebViewControllerDelegate? = nil) { + let navigationViewController = makeBlazeWebViewController(source: source, blog: blog, postID: postID, delegate: delegate) + viewController.present(navigationViewController, animated: true) + } + + /// Creates and returns a configured Blaze web view controller wrapped in a navigation controller, + /// without presenting it. + static func makeBlazeWebViewController( + source: BlazeSource, + blog: Blog, + postID: NSNumber? = nil, + delegate: BlazeWebViewControllerDelegate? = nil + ) -> UINavigationController { let blazeViewController = BlazeWebViewController(delegate: delegate) let viewModel = BlazeCreateCampaignWebViewModel(source: source, blog: blog, @@ -92,7 +104,7 @@ import WordPressData let navigationViewController = UINavigationController(rootViewController: blazeViewController) navigationViewController.overrideUserInterfaceStyle = .light navigationViewController.modalPresentationStyle = .formSheet - viewController.present(navigationViewController, animated: true) + return navigationViewController } /// Used to display the blaze overlay. diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index bb4b5bbd7f4d..59cda0853eb6 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -1,8 +1,10 @@ import Foundation import SwiftUI +import UIKit import WordPressAPI import WordPressAPIInternal import WordPressCore +import WordPressData import WordPressUI /// Displays a paginated list of custom posts. @@ -49,6 +51,7 @@ struct CustomPostListView: View { var body: some View { PaginatedList( + viewModel: viewModel, items: viewModel.items, onLoadNextPage: { try await viewModel.loadNextPage() }, client: client, @@ -71,6 +74,7 @@ struct CustomPostListView: View { .refreshable { await viewModel.refresh() } + .progressHUD(state: $viewModel.progressHUDState) .task(id: viewModel.filter) { await viewModel.loadCachedItems() await viewModel.refresh() @@ -78,10 +82,53 @@ struct CustomPostListView: View { .task(id: viewModel.filter) { await viewModel.handleDataChanges() } + .alert( + Strings.deleteConfirmationTitle, + isPresented: Binding( + get: { viewModel.postToDelete != nil }, + set: { if !$0 { viewModel.postToDelete = nil } } + ), + presenting: viewModel.postToDelete + ) { post in + Button(SharedStrings.Button.cancel, role: .cancel) {} + Button(Strings.deletePermanently, role: .destructive) { + Task { await viewModel.deletePost(post) } + } + } message: { _ in + Text(Strings.deleteConfirmationMessage) + } + .sheet(item: $viewModel.menuNavigation) { + menuNavigationDestination($0) + } + } + + @ViewBuilder + private func menuNavigationDestination(_ navigation: CustomPostListViewModel.PostMenuNavigation) -> some View { + switch navigation { + case .stats(let post): + StatsRepresentable( + postID: Int(post.id), + postTitle: post.title?.raw, + postURL: URL(string: post.link) + ) + case .comments(let post, let siteID): + CommentsRepresentable(postID: post.id, siteID: siteID) + case .blaze(let post): + BlazeRepresentable(postID: post.id, blog: viewModel.blog) + case .settings(let post): + SettingsRepresentable( + post: post, + blog: viewModel.blog, + client: viewModel.client, + service: viewModel.postService, + details: details + ) + } } } private struct PaginatedList: View { + let viewModel: CustomPostListViewModel let items: [CustomPostCollectionItem] let onLoadNextPage: () async throws -> Void let client: WordPressClient? @@ -93,12 +140,14 @@ private struct PaginatedList: View { @State var loadMoreError: Error? init( + viewModel: CustomPostListViewModel, items: [CustomPostCollectionItem], onLoadNextPage: @escaping () async throws -> Void, client: WordPressClient? = nil, onSelectPost: @escaping (AnyPostWithEditContext) -> Void, mediaHost: MediaHost? = nil ) where Header == EmptyView { + self.viewModel = viewModel self.items = items self.onLoadNextPage = onLoadNextPage self.client = client @@ -108,6 +157,7 @@ private struct PaginatedList: View { } init( + viewModel: CustomPostListViewModel, items: [CustomPostCollectionItem], onLoadNextPage: @escaping () async throws -> Void, client: WordPressClient? = nil, @@ -115,6 +165,7 @@ private struct PaginatedList: View { mediaHost: MediaHost? = nil, @ViewBuilder header: @escaping () -> Header ) { + self.viewModel = viewModel self.items = items self.onLoadNextPage = onLoadNextPage self.client = client @@ -133,7 +184,7 @@ private struct PaginatedList: View { .listSectionSeparator(.hidden) ForEach(items) { item in - ForEachContent(item: item, client: client, onSelectPost: onSelectPost, mediaHost: mediaHost) + ForEachContent(item: item, client: client, onSelectPost: onSelectPost, mediaHost: mediaHost, viewModel: viewModel) .task { await onRowAppear(item: item) } @@ -195,6 +246,7 @@ private struct ForEachContent: View { let client: WordPressClient? let onSelectPost: (AnyPostWithEditContext) -> Void let mediaHost: MediaHost? + let viewModel: CustomPostListViewModel var body: some View { switch item { @@ -226,6 +278,10 @@ private struct ForEachContent: View { PostContent(post: displayPost, client: client, mediaHost: mediaHost) } .buttonStyle(.plain) + .overlay(alignment: .topTrailing) { + PostActionMenu(post: post, viewModel: viewModel) + .offset(y: -6) + } case .stale(_, let post): PostContent(post: post, client: client, mediaHost: mediaHost) @@ -233,11 +289,88 @@ private struct ForEachContent: View { } } +private struct PostActionMenu: View { + let post: AnyPostWithEditContext + let viewModel: CustomPostListViewModel + + var body: some View { + Menu { + primarySection + navigationSection + trashSection + } label: { + Image(systemName: "ellipsis") + .font(.body) + .tint(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + } + + @ViewBuilder + private var primarySection: some View { + Section { + if post.status != .trash { + Button(action: { viewModel.viewPost(post) }) { + Label(SharedStrings.Button.view, systemImage: "safari") + } + + // FIXME: Preview requires Core Data preview infrastructure (PreviewNonceHandler, AbstractPost) + } + + if post.status == .draft || post.status == .pending { + Button(action: { Task { await viewModel.publishPost(post) } }) { + Label(Strings.publish, systemImage: "paperplane") + } + } + + if post.status != .draft { + Button(action: { Task { await viewModel.moveToDraft(post) } }) { + Label(Strings.moveToDraft, systemImage: "pencil") + } + } + + // FIXME: Duplicate requires Core Data editor (Post.blog.createDraftPost, PostListEditorPresenter) + + if post.status == .publish, let url = URL(string: post.link) { + ShareLink(item: url, subject: Text(post.title?.raw ?? "")) { + Label(SharedStrings.Button.share, systemImage: "square.and.arrow.up") + } + } + } + } + + @ViewBuilder + private var navigationSection: some View { + Section { + ForEach(viewModel.navigationMenuItems(for: post)) { navigation in + Button(action: { viewModel.menuNavigation = navigation }) { + Label(navigation.label, systemImage: navigation.systemImage) + } + } + } + } + + @ViewBuilder + private var trashSection: some View { + Section { + if post.status != .trash { + Button(role: .destructive, action: { Task { await viewModel.trashPost(post) } }) { + Label(Strings.moveToTrash, systemImage: "trash") + } + } else { + Button(role: .destructive, action: { viewModel.confirmDelete(post) }) { + Label(Strings.deletePermanently, systemImage: "trash.fill") + } + } + } + } +} + private struct PostContent: View { let post: CustomPostCollectionDisplayPost let client: WordPressClient? let mediaHost: MediaHost? - var showsEllipsisMenu: Bool = false var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -254,12 +387,6 @@ private struct PostContent: View { Text(verbatim: post.headerBadges) .font(.footnote) .foregroundStyle(.secondary) - - Spacer() - - if showsEllipsisMenu { - ellipsisMenu - } } } @@ -295,21 +422,6 @@ private struct PostContent: View { .foregroundStyle(post.statusColor) } } - - private var ellipsisMenu: some View { - // TODO: To be implemented - Menu { - Button(action: { Loggers.app.info("View tapped") }) { - Label(SharedStrings.Button.view, systemImage: "safari") - } - } label: { - Image(systemName: "ellipsis") - .font(.body) - .foregroundStyle(.secondary) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - } } private struct ErrorRow: View { @@ -328,130 +440,118 @@ private struct ErrorRow: View { } } +// MARK: - UIViewControllerRepresentable + +private struct StatsRepresentable: UIViewControllerRepresentable { + let postID: Int + let postTitle: String? + let postURL: URL? + + func makeUIViewController(context: Context) -> UIViewController { + let statsVC = PostStatsTableViewController.withJPBannerForBlog( + postID: postID, + postTitle: postTitle, + postURL: postURL + ) + return UINavigationController(rootViewController: statsVC) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +private struct CommentsRepresentable: UIViewControllerRepresentable { + let postID: Int64 + let siteID: NSNumber + + func makeUIViewController(context: Context) -> UIViewController { + let commentsVC = ReaderCommentsViewController( + postID: NSNumber(value: postID), + siteID: siteID + ) + return UINavigationController(rootViewController: commentsVC) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +private struct BlazeRepresentable: UIViewControllerRepresentable { + let postID: Int64 + let blog: Blog + + func makeUIViewController(context: Context) -> UINavigationController { + BlazeFlowCoordinator.makeBlazeWebViewController( + source: .postsList, + blog: blog, + postID: NSNumber(value: postID) + ) + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} +} + +private struct SettingsRepresentable: UIViewControllerRepresentable { + let post: AnyPostWithEditContext + let blog: Blog + let client: WordPressClient + let service: WordPressAPIInternal.PostService + let details: PostTypeDetailsWithEditContext + + func makeUIViewController(context: Context) -> UIViewController { + let editorService = CustomPostEditorService( + blog: blog, + post: post, + details: details, + client: client, + service: service + ) + let viewModel = PostSettingsViewModel(editorService: editorService, blog: blog, isStandalone: true) + let settingsVC = PostSettingsViewController(viewModel: viewModel) + return UINavigationController(rootViewController: settingsVC) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + private enum Strings { static let emptyStateMessage = NSLocalizedString( "customPostList.emptyState.message", value: "No %1$@", comment: "Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')." ) - static let trashButton = NSLocalizedString( - "customPostList.action.trash", - value: "Trash", - comment: "Button title to move a post to trash" + static let publish = NSLocalizedString( + "customPostList.action.publish", + value: "Publish", + comment: "Menu action to publish a draft or pending post" ) -} - -// MARK: - Previews - -#Preview("Fetching Placeholders") { - PaginatedList( - items: [ - .fetching(id: 1), - .fetching(id: 2), - .fetching(id: 3) - ], - onLoadNextPage: {}, - onSelectPost: { _ in } + static let moveToDraft = NSLocalizedString( + "customPostList.action.moveToDraft", + value: "Move to Draft", + comment: "Menu action to change a post's status to draft" ) -} - -#Preview("Error State") { - PaginatedList( - items: [ - .error(id: 1, message: "Failed to load post"), - .error(id: 2, message: "Network connection lost") - ], - onLoadNextPage: {}, - onSelectPost: { _ in } + static let moveToTrash = NSLocalizedString( + "customPostList.action.moveToTrash", + value: "Move to Trash", + comment: "Menu action to move a post to trash" ) -} - -#Preview("Stale Content") { - PaginatedList( - items: [ - .stale( - id: 1, - post: CustomPostCollectionDisplayPost( - date: .now, - title: "First Draft Post", - content: "This is a preview of the first post that might be outdated." - ) - ), - .stale( - id: 2, - post: CustomPostCollectionDisplayPost( - date: .now.addingTimeInterval(-86400), - title: "Second Post", - content: "Another post with stale data showing in the list." - ) - ), - .stale( - id: 3, - post: CustomPostCollectionDisplayPost( - date: .now.addingTimeInterval(-86400 * 7), - title: nil, - content: "Post without a title" - ) - ) - ], - onLoadNextPage: {}, - onSelectPost: { _ in } + static let deletePermanently = NSLocalizedString( + "customPostList.action.deletePermanently", + value: "Delete Permanently", + comment: "Menu action to permanently delete a trashed post" ) -} - -#Preview("Mixed States") { - PaginatedList( - items: [ - .stale( - id: 1, - post: CustomPostCollectionDisplayPost( - date: .now, - title: "Published Post", - content: "This post has stale data and is being refreshed." - ) - ), - .refreshing( - id: 2, - post: CustomPostCollectionDisplayPost( - date: .now.addingTimeInterval(-86400), - title: "Refreshing Post", - content: "Currently being refreshed in the background." - ) - ), - .fetching(id: 3), - .error(id: 4, message: "Failed to sync"), - .errorWithData( - id: 5, - message: "Sync failed, showing cached data", - post: CustomPostCollectionDisplayPost( - date: .now.addingTimeInterval(-86400 * 3), - title: "Cached Post", - content: "This post failed to sync but we have old data." - ) - ), - ], - onLoadNextPage: {}, - onSelectPost: { _ in } + static let deleteConfirmationTitle = NSLocalizedString( + "customPostList.deleteConfirmation.title", + value: "Delete Permanently?", + comment: "Title for the confirmation alert when permanently deleting a post" ) -} - -#Preview("Load Next Page Error") { - PaginatedList( - items: [ - .stale( - id: 1, - post: CustomPostCollectionDisplayPost( - date: .now, - title: "Published Post", - content: "This post has stale data and is being refreshed." - ) - ), - ], - onLoadNextPage: { throw CollectionError.DatabaseError(errMessage: "SQL error") }, - onSelectPost: { _ in }, + static let deleteConfirmationMessage = NSLocalizedString( + "customPostList.deleteConfirmation.message", + value: "This action cannot be undone.", + comment: "Message for the confirmation alert when permanently deleting a post" ) } +// MARK: - Previews + #Preview("Status Variants") { List { PostContent( @@ -487,19 +587,3 @@ private enum Strings { } .listStyle(.plain) } - -#Preview("Flags: No Menu") { - List { - PostContent( - post: CustomPostCollectionDisplayPost( - date: .now, - title: "Minimal Row", - content: "No ellipsis menu." - ), - client: nil, - mediaHost: nil, - showsEllipsisMenu: false - ) - } - .listStyle(.plain) -} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 9f343deec153..c67a86506172 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit import WordPressAPI import WordPressAPIInternal import WordPressCore @@ -8,9 +9,11 @@ import WordPressShared @MainActor final class CustomPostListViewModel: ObservableObject { - private let client: WordPressClient + let client: WordPressClient + private let service: WpService private let endpoint: PostEndpointType - private let blog: Blog + private let details: PostTypeDetailsWithEditContext + let blog: Blog let filter: CustomPostListFilter private var collection: PostMetadataCollectionWithEditContext @@ -18,6 +21,9 @@ final class CustomPostListViewModel: ObservableObject { @Published private(set) var items: [CustomPostCollectionItem] = [] @Published private(set) var listInfo: ListInfo? @Published private var error: Error? + @Published var postToDelete: AnyPostWithEditContext? + @Published var menuNavigation: PostMenuNavigation? + @Published var progressHUDState: ProgressHUDState = .idle var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false @@ -27,6 +33,10 @@ final class CustomPostListViewModel: ObservableObject { items.isEmpty && listInfo?.isSyncing == true } + var postService: WordPressAPIInternal.PostService { + service.posts() + } + func errorToDisplay() -> Error? { items.isEmpty ? error : nil } @@ -34,12 +44,14 @@ final class CustomPostListViewModel: ObservableObject { init( client: WordPressClient, service: WpService, - endpoint: PostEndpointType, + details: PostTypeDetailsWithEditContext, filter: CustomPostListFilter, blog: Blog ) { self.client = client - self.endpoint = endpoint + self.service = service + self.endpoint = details.toPostEndpointType() + self.details = details self.blog = blog self.filter = filter @@ -56,7 +68,7 @@ final class CustomPostListViewModel: ObservableObject { do { _ = try await collection.refresh() } catch { - DDLogError("Failed to refresh posts: \(error)") + Loggers.app.error("Failed to refresh posts: \(error)") self.show(error: error) } } @@ -85,7 +97,7 @@ final class CustomPostListViewModel: ObservableObject { self.items = items } } catch { - DDLogError("Failed to load cached items: \(error)") + Loggers.app.error("Failed to load cached items: \(error)") } } @@ -95,17 +107,17 @@ final class CustomPostListViewModel: ObservableObject { .collect(.byTime(DispatchQueue.main, .milliseconds(50))) .values for await batch in batches { - DDLogInfo("\(batch.count) updates received from WpApiCache") + Loggers.app.info("\(batch.count) updates received from WpApiCache") #if DEBUG for hook in batch { - DDLogDebug(" |- \(hook.action) to \(hook.table) at row \(hook.rowId)") + Loggers.app.debug(" |- \(hook.action) to \(hook.table) at row \(hook.rowId)") } #endif let listInfo = collection.listInfo() - DDLogInfo("List info: \(String(describing: listInfo))") + Loggers.app.info("List info: \(String(describing: listInfo))") do { let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, filterStatus: filter.status) } @@ -118,11 +130,105 @@ final class CustomPostListViewModel: ObservableObject { } } } catch { - DDLogError("Failed to get collection items: \(error)") + Loggers.app.error("Failed to get collection items: \(error)") } } } + // MARK: - Post Actions + + func confirmDelete(_ post: AnyPostWithEditContext) { + postToDelete = post + } + + func publishPost(_ post: AnyPostWithEditContext) async { + var params = PostUpdateParams(meta: nil) + params.status = .publish + await updatePost(post, params: params) + } + + func moveToDraft(_ post: AnyPostWithEditContext) async { + var params = PostUpdateParams(meta: nil) + params.status = .draft + await updatePost(post, params: params) + } + + func viewPost(_ post: AnyPostWithEditContext) { + guard let url = URL(string: post.link) else { return } + UIApplication.shared.open(url) + } + + func navigationMenuItems(for post: AnyPostWithEditContext) -> [PostMenuNavigation] { + var items: [PostMenuNavigation] = [] + if let nav = menuNavigation(forBlaze: post) { + items.append(nav) + } + if let nav = menuNavigation(forStats: post) { + items.append(nav) + } + if let nav = menuNavigation(forComments: post) { + items.append(nav) + } + if post.status != .trash { + items.append(.settings(post: post)) + } + return items + } + + func menuNavigation(forBlaze post: AnyPostWithEditContext) -> PostMenuNavigation? { + guard endpoint == .posts + && BlazeHelper.isBlazeFlagEnabled() && blog.canBlaze + && post.status == .publish && post.password == nil else { return nil } + return .blaze(post: post) + } + + func menuNavigation(forStats post: AnyPostWithEditContext) -> PostMenuNavigation? { + guard endpoint == .posts + && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + && blog.supports(.stats) && post.status == .publish else { return nil } + return .stats(post: post) + } + + func menuNavigation(forComments post: AnyPostWithEditContext) -> PostMenuNavigation? { + guard details.supports.supports(feature: .comments) + && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + && post.status == .publish, let siteID = blog.dotComID else { return nil } + return .comments(post: post, siteID: siteID) + } + + func trashPost(_ post: AnyPostWithEditContext) async { + progressHUDState = .running + do { + _ = try await service.posts().trashPost(endpointType: endpoint, postId: post.id) + progressHUDState = .success + } catch { + Loggers.app.error("Failed to trash post: \(error)") + progressHUDState = .failure(error.localizedDescription) + } + } + + func deletePost(_ post: AnyPostWithEditContext) async { + progressHUDState = .running + do { + _ = try await service.posts().deletePostPermanently(endpointType: endpoint, postId: post.id) + progressHUDState = .success + } catch { + Loggers.app.error("Failed to delete post: \(error)") + progressHUDState = .failure(error.localizedDescription) + } + } + + private func updatePost(_ post: AnyPostWithEditContext, params: PostUpdateParams) async { + progressHUDState = .running + do { + _ = try await service.posts().updatePost(endpointType: endpoint, postId: post.id, params: params) + progressHUDState = .success + } catch { + Loggers.app.error("Failed to update post: \(error)") + progressHUDState = .failure(error.localizedDescription) + } + } + private func show(error: Error) { self.error = error @@ -133,6 +239,44 @@ final class CustomPostListViewModel: ObservableObject { } } +extension CustomPostListViewModel { + + enum PostMenuNavigation: Identifiable { + case stats(post: AnyPostWithEditContext) + case comments(post: AnyPostWithEditContext, siteID: NSNumber) + case blaze(post: AnyPostWithEditContext) + case settings(post: AnyPostWithEditContext) + + var id: String { + switch self { + case .stats(let post): return "stats-\(post.id)" + case .comments(let post, let siteId): return "site-\(siteId)-comments-\(post.id)" + case .blaze(let post): return "blaze-\(post.id)" + case .settings(let post): return "settings-\(post.id)" + } + } + + var label: String { + switch self { + case .blaze: return Strings.blaze + case .stats: return Strings.stats + case .comments: return Strings.comments + case .settings: return Strings.settings + } + } + + var systemImage: String { + switch self { + case .blaze: return "flame" + case .stats: return "chart.bar" + case .comments: return "bubble.left" + case .settings: return "gearshape" + } + } + } + +} + struct CustomPostCollectionDisplayPost: Equatable { let date: Date let title: String? @@ -252,14 +396,6 @@ struct CustomPostCollectionDisplayPost: Equatable { ) } -private enum Strings { - static let sticky = NSLocalizedString( - "customPostList.badge.sticky", - value: "Sticky", - comment: "Badge shown in the post list for sticky posts" - ) -} - extension PostStatus { func localizedLabel() -> String { switch self { @@ -342,3 +478,31 @@ private extension ListInfo { return currentPage < totalPages } } + +private enum Strings { + static let sticky = NSLocalizedString( + "customPostList.badge.sticky", + value: "Sticky", + comment: "Badge shown in the post list for sticky posts" + ) + static let blaze = NSLocalizedString( + "customPostList.action.blaze", + value: "Promote with Blaze", + comment: "Menu action to promote a post with Blaze" + ) + static let stats = NSLocalizedString( + "customPostList.action.stats", + value: "Stats", + comment: "Menu action to view post statistics" + ) + static let comments = NSLocalizedString( + "customPostList.action.comments", + value: "Comments", + comment: "Menu action to view post comments" + ) + static let settings = NSLocalizedString( + "customPostList.action.settings", + value: "Settings", + comment: "Menu action to open post settings" + ) +} diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift index 7055a37131ea..02cade60cb68 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift @@ -9,7 +9,6 @@ struct CustomPostSearchResultView: View { let blog: Blog let client: WordPressClient let service: WpService - let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext @Binding var searchText: String let onSelectPost: (AnyPostWithEditContext) -> Void @@ -21,7 +20,7 @@ struct CustomPostSearchResultView: View { viewModel: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter.default.with(search: finalSearchText), blog: blog ), diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift index b47143f160dc..cf9368f05217 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -11,7 +11,6 @@ import DesignSystem struct CustomPostTabView: View { let client: WordPressClient let service: WpService - let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext let blog: Blog @@ -43,48 +42,46 @@ struct CustomPostTabView: View { init( client: WordPressClient, service: WpService, - endpoint: PostEndpointType, details: PostTypeDetailsWithEditContext, blog: Blog ) { self.client = client self.service = service - self.endpoint = endpoint self.details = details self.blog = blog _allViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter(status: .custom("any")), blog: blog )) _publishedViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter(status: .publish), blog: blog )) _draftsViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter(status: .draft), blog: blog )) _scheduledViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter(status: .future), blog: blog )) _trashViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, + details: details, filter: CustomPostListFilter(status: .trash), blog: blog )) @@ -106,7 +103,6 @@ struct CustomPostTabView: View { blog: blog, client: client, service: service, - endpoint: endpoint, details: details, searchText: $searchText, onSelectPost: { editorPresentation = .editPost($0) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index 72c46d35f90a..f8728d0c9933 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -106,7 +106,7 @@ struct CustomPostTypesView: View { let isPinned = pinnedTypes.contains { $0.slug == details.slug } return NavigationLink { if let wpService = service.wpService { - CustomPostTabView(client: service.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) + CustomPostTabView(client: service.client, service: wpService, details: details, blog: blog) } else { let _ = wpAssertionFailure("Expected wpService to be available") } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift index 02ad1efd74b6..0c5c2a308622 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift @@ -28,7 +28,7 @@ struct PinnedPostTypeView: View { var body: some View { Group { if let details, let wpService = customPostTypeService.wpService { - CustomPostTabView(client: customPostTypeService.client, service: wpService, endpoint: details.toPostEndpointType(), details: details, blog: blog) + CustomPostTabView(client: customPostTypeService.client, service: wpService, details: details, blog: blog) } else if isLoading { ProgressView() .progressViewStyle(.circular) diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 02ef79603407..ea226a770e53 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -302,13 +302,14 @@ final class PostSettingsViewModel: NSObject, ObservableObject { init( editorService: CustomPostEditorService, blog: Blog, + isStandalone: Bool = false, context: Context = .settings, preferences: UserPersistentRepository = UserDefaults.standard ) { self.details = .customPost(editorService) self.blog = blog self.capabilities = PostSettingsCapabilities(from: editorService.details) - self.isStandalone = false + self.isStandalone = isStandalone self.context = context self.preferences = preferences self.client = editorService.client From 135104f53f3e67ceec4d35ea206b6ce54a425271 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 11:00:08 +1300 Subject: [PATCH 2/8] Only allow viewing published posts --- .../ViewRelated/CustomPostTypes/CustomPostListView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 59cda0853eb6..c9aa08d688c6 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -310,14 +310,14 @@ private struct PostActionMenu: View { @ViewBuilder private var primarySection: some View { Section { - if post.status != .trash { + if post.status == .publish { Button(action: { viewModel.viewPost(post) }) { Label(SharedStrings.Button.view, systemImage: "safari") } - - // FIXME: Preview requires Core Data preview infrastructure (PreviewNonceHandler, AbstractPost) } + // FIXME: Preview requires Core Data preview infrastructure (PreviewNonceHandler, AbstractPost) + if post.status == .draft || post.status == .pending { Button(action: { Task { await viewModel.publishPost(post) } }) { Label(Strings.publish, systemImage: "paperplane") From 50454755e97820bc5c5db0bc94a4d64b85d5578a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 11:08:32 +1300 Subject: [PATCH 3/8] Show the same more actions in the context menu --- .../CustomPostTypes/CustomPostListView.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index c9aa08d688c6..a074b88910d1 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -278,6 +278,9 @@ private struct ForEachContent: View { PostContent(post: displayPost, client: client, mediaHost: mediaHost) } .buttonStyle(.plain) + .contextMenu { + PostActionMenuContent(post: post, viewModel: viewModel) + } .overlay(alignment: .topTrailing) { PostActionMenu(post: post, viewModel: viewModel) .offset(y: -6) @@ -295,9 +298,7 @@ private struct PostActionMenu: View { var body: some View { Menu { - primarySection - navigationSection - trashSection + PostActionMenuContent(post: post, viewModel: viewModel) } label: { Image(systemName: "ellipsis") .font(.body) @@ -306,6 +307,17 @@ private struct PostActionMenu: View { .contentShape(Rectangle()) } } +} + +private struct PostActionMenuContent: View { + let post: AnyPostWithEditContext + let viewModel: CustomPostListViewModel + + var body: some View { + primarySection + navigationSection + trashSection + } @ViewBuilder private var primarySection: some View { From 2fc012d7b04e18fe5094decbbbf9c9d48a4697fa Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 11:18:03 +1300 Subject: [PATCH 4/8] Require confirmation when trashing published posts --- .../CustomPostTypes/CustomPostListView.swift | 33 ++++++++++++++++++- .../CustomPostListViewModel.swift | 5 +++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index a074b88910d1..1ac1d0216e4a 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -97,6 +97,21 @@ struct CustomPostListView: View { } message: { _ in Text(Strings.deleteConfirmationMessage) } + .alert( + Strings.trashConfirmationTitle, + isPresented: Binding( + get: { viewModel.postToTrash != nil }, + set: { if !$0 { viewModel.postToTrash = nil } } + ), + presenting: viewModel.postToTrash + ) { post in + Button(SharedStrings.Button.cancel, role: .cancel) {} + Button(Strings.moveToTrash, role: .destructive) { + Task { await viewModel.trashPost(post) } + } + } message: { _ in + Text(Strings.trashConfirmationMessage) + } .sheet(item: $viewModel.menuNavigation) { menuNavigationDestination($0) } @@ -367,7 +382,13 @@ private struct PostActionMenuContent: View { private var trashSection: some View { Section { if post.status != .trash { - Button(role: .destructive, action: { Task { await viewModel.trashPost(post) } }) { + Button(role: .destructive, action: { + if post.status == .publish { + viewModel.confirmTrash(post) + } else { + Task { await viewModel.trashPost(post) } + } + }) { Label(Strings.moveToTrash, systemImage: "trash") } } else { @@ -550,6 +571,16 @@ private enum Strings { value: "Delete Permanently", comment: "Menu action to permanently delete a trashed post" ) + static let trashConfirmationTitle = NSLocalizedString( + "customPostList.trashConfirmation.title", + value: "Move to Trash?", + comment: "Title for the confirmation alert when trashing a published post" + ) + static let trashConfirmationMessage = NSLocalizedString( + "customPostList.trashConfirmation.message", + value: "This post is published and visible to visitors. Are you sure you want to move it to trash?", + comment: "Message for the confirmation alert when trashing a published post" + ) static let deleteConfirmationTitle = NSLocalizedString( "customPostList.deleteConfirmation.title", value: "Delete Permanently?", diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index c67a86506172..ac1738d8bfbe 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -22,6 +22,7 @@ final class CustomPostListViewModel: ObservableObject { @Published private(set) var listInfo: ListInfo? @Published private var error: Error? @Published var postToDelete: AnyPostWithEditContext? + @Published var postToTrash: AnyPostWithEditContext? @Published var menuNavigation: PostMenuNavigation? @Published var progressHUDState: ProgressHUDState = .idle @@ -141,6 +142,10 @@ final class CustomPostListViewModel: ObservableObject { postToDelete = post } + func confirmTrash(_ post: AnyPostWithEditContext) { + postToTrash = post + } + func publishPost(_ post: AnyPostWithEditContext) async { var params = PostUpdateParams(meta: nil) params.status = .publish From 34fab64cc8dcd59cf0990904138aa80497d41cd3 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 12:25:29 +1300 Subject: [PATCH 5/8] Use multiple status in tabs --- .../CustomPostListViewModel.swift | 32 ++++++++-------- .../CustomPostSearchResultView.swift | 2 +- .../CustomPostTypes/CustomPostTabView.swift | 38 +++++++++++++++---- .../ViewRelated/CustomPostTypes/Filter.swift | 38 ++++++++++++++----- 4 files changed, 75 insertions(+), 35 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index ac1738d8bfbe..d96c957d190d 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -90,7 +90,7 @@ final class CustomPostListViewModel: ObservableObject { let listInfo = collection.listInfo() do { - let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, filterStatus: filter.status) } + let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, primaryStatus: filter.primaryStatus) } if self.listInfo != listInfo { self.listInfo = listInfo } @@ -121,7 +121,7 @@ final class CustomPostListViewModel: ObservableObject { Loggers.app.info("List info: \(String(describing: listInfo))") do { - let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, filterStatus: filter.status) } + let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, primaryStatus: filter.primaryStatus) } withAnimation { if self.listInfo != listInfo { self.listInfo = listInfo @@ -290,7 +290,7 @@ struct CustomPostCollectionDisplayPost: Equatable { let status: PostStatus let sticky: Bool let featuredMedia: MediaId? - let filterStatus: PostStatus? + let primaryStatus: PostStatus init( date: Date, @@ -300,7 +300,7 @@ struct CustomPostCollectionDisplayPost: Equatable { status: PostStatus = .publish, sticky: Bool = false, featuredMedia: MediaId? = nil, - filterStatus: PostStatus? = nil + primaryStatus: PostStatus = .publish ) { self.date = date self.title = title @@ -309,10 +309,10 @@ struct CustomPostCollectionDisplayPost: Equatable { self.status = status self.sticky = sticky self.featuredMedia = featuredMedia - self.filterStatus = filterStatus + self.primaryStatus = primaryStatus } - init(_ entity: AnyPostWithEditContext, blog: Blog, contentLimit: Int = 100, filterStatus: PostStatus? = nil) { + init(_ entity: AnyPostWithEditContext, blog: Blog, contentLimit: Int = 100, primaryStatus: PostStatus = .publish) { self.date = entity.dateGmt self.title = entity.title?.raw let contentPreview = GutenbergExcerptGenerator @@ -334,7 +334,7 @@ struct CustomPostCollectionDisplayPost: Equatable { self.status = entity.status self.sticky = entity.sticky ?? false self.featuredMedia = entity.featuredMedia - self.filterStatus = filterStatus + self.primaryStatus = primaryStatus } /// The title to display, with a placeholder for untitled posts. @@ -372,11 +372,9 @@ struct CustomPostCollectionDisplayPost: Equatable { var statusBadges: String? { var badges: [String] = [] - // Each tab filters by a specific status. Show a status badge when the - // post's status doesn't match the tab's filter, since it would be redundant - // otherwise. The "All" tab uses `.custom("any")` which never matches any - // post status, so non-published posts always get a badge there. - let showStatus = filterStatus == .custom("any") ? status != .publish : status != filterStatus + // Show a status badge when the post's status isn't one of the filter's + // statuses, since it would be redundant otherwise. + let showStatus = status != primaryStatus if showStatus { badges.append(status.localizedLabel()) } @@ -445,18 +443,18 @@ enum CustomPostCollectionItem: Identifiable, Equatable { } } - init(item: PostMetadataCollectionItem, blog: Blog, filterStatus: PostStatus? = nil) { + init(item: PostMetadataCollectionItem, blog: Blog, primaryStatus: PostStatus = .publish) { let id = item.id switch item.state { case .fresh(let entity): - self = .ready(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, filterStatus: filterStatus), fullPost: entity.data) + self = .ready(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, primaryStatus: primaryStatus), fullPost: entity.data) case .stale(let entity): - self = .stale(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, filterStatus: filterStatus)) + self = .stale(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, primaryStatus: primaryStatus)) case .fetchingWithData(let entity): - self = .refreshing(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, filterStatus: filterStatus)) + self = .refreshing(id: id, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, primaryStatus: primaryStatus)) case .fetching: self = .fetching(id: id) @@ -468,7 +466,7 @@ enum CustomPostCollectionItem: Identifiable, Equatable { self = .error(id: id, message: error) case .failedWithData(let error, let entity): - self = .errorWithData(id: id, message: error, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, contentLimit: 50, filterStatus: filterStatus)) + self = .errorWithData(id: id, message: error, post: CustomPostCollectionDisplayPost(entity.data, blog: blog, contentLimit: 50, primaryStatus: primaryStatus)) } } } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift index 02cade60cb68..324f58f2ae1f 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift @@ -21,7 +21,7 @@ struct CustomPostSearchResultView: View { client: client, service: service, details: details, - filter: CustomPostListFilter.default.with(search: finalSearchText), + filter: .search(input: finalSearchText), blog: blog ), details: details, diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift index cf9368f05217..74795bb4a888 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -54,35 +54,35 @@ struct CustomPostTabView: View { client: client, service: service, details: details, - filter: CustomPostListFilter(status: .custom("any")), + filter: CustomPostListFilter(tab: .all), blog: blog )) _publishedViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, - filter: CustomPostListFilter(status: .publish), + filter: CustomPostListFilter(tab: .published), blog: blog )) _draftsViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, - filter: CustomPostListFilter(status: .draft), + filter: CustomPostListFilter(tab: .drafts), blog: blog )) _scheduledViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, - filter: CustomPostListFilter(status: .future), + filter: CustomPostListFilter(tab: .scheduled), blog: blog )) _trashViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, - filter: CustomPostListFilter(status: .trash), + filter: CustomPostListFilter(tab: .trash), blog: blog )) } @@ -175,15 +175,39 @@ enum CustomPostTab: Int, CaseIterable, AdaptiveTabBarItem { } } - var status: PostStatus { + var primaryStatus: PostStatus { switch self { - case .all: return .custom("any") + case .all: return .publish case .published: return .publish case .drafts: return .draft case .scheduled: return .future case .trash: return .trash } } + + var statuses: [PostStatus] { + switch self { + case .all: return [.custom("any")] + case .published: return [.publish, .private] + case .drafts: return [.draft, .pending] + case .scheduled: return [.future] + case .trash: return [.trash] + } + } + + var orderby: WpApiParamPostsOrderBy { + switch self { + case .all, .drafts: return .modified + case .published, .scheduled, .trash: return .date + } + } + + var order: WpApiParamOrder { + switch self { + case .scheduled: return .asc + case .all, .published, .drafts, .trash: return .desc + } + } } private struct AdaptiveTabBarRepresentable: UIViewRepresentable { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift index eb34423ebf5d..a576e6ec17fc 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/Filter.swift @@ -3,19 +3,35 @@ import WordPressAPI import WordPressAPIInternal struct CustomPostListFilter: Equatable { - var status: PostStatus + var statuses: [PostStatus] + var primaryStatus: PostStatus + var order: WpApiParamOrder + var orderby: WpApiParamPostsOrderBy var search: String? - static var `default`: Self { - get { - .init(status: .custom("any")) - } + init( + statuses: [PostStatus], + primaryStatus: PostStatus = .publish, + order: WpApiParamOrder = .desc, + orderby: WpApiParamPostsOrderBy = .date, + search: String? = nil + ) { + self.statuses = statuses + self.primaryStatus = primaryStatus + self.order = order + self.orderby = orderby + self.search = search } - func with(search: String) -> Self { - var copy = self - copy.search = search - return copy + init(tab: CustomPostTab) { + self.statuses = tab.statuses + self.primaryStatus = tab.primaryStatus + self.order = tab.order + self.orderby = tab.orderby + } + + static func search(input: String) -> Self { + .init(statuses: [.custom("any")], search: input) } func asPostListFilter() -> WordPressAPIInternal.PostListFilter { @@ -23,7 +39,9 @@ struct CustomPostListFilter: Equatable { search: search, // TODO: Support author? searchColumns: search == nil ? [] : [.postTitle, .postContent, .postExcerpt], - status: [status], + order: order, + orderby: orderby, + status: statuses ) } } From f8edf5ca1d06369c9c8aa9329c4c0b76fa46a030 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 13:58:39 +1300 Subject: [PATCH 6/8] Replace UIViewControllerRepresentable with UIKit presentation --- .../BlogDetailsViewController+Swift.swift | 17 ++- .../CustomPostTypes/CustomPostListView.swift | 100 +----------------- .../CustomPostListViewModel.swift | 59 +++++++++-- .../CustomPostSearchResultView.swift | 5 +- .../CustomPostTypes/CustomPostTabView.swift | 21 ++-- .../CustomPostTypes/CustomPostTypesView.swift | 7 +- .../CustomPostTypes/PinnedPostTypeView.swift | 7 +- 7 files changed, 91 insertions(+), 125 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index d90ba0b0d354..5e37ebbc0eda 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -95,8 +95,12 @@ extension BlogDetailsViewController { let rootView = ApplicationPasswordRequiredView( blog: blog, localizedFeatureName: feature, - presentingViewController: self) { [blog] client in - CustomPostTypesView(blog: blog, service: CustomPostTypeService(client: client, blog: blog)) + presentingViewController: self) { [blog, weak self] client in + CustomPostTypesView( + blog: blog, + service: CustomPostTypeService(client: client, blog: blog), + presentingViewController: self + ) } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never @@ -114,8 +118,13 @@ extension BlogDetailsViewController { let rootView = ApplicationPasswordRequiredView( blog: blog, localizedFeatureName: feature, - presentingViewController: self) { [blog] client in - PinnedPostTypeView(blog: blog, service: CustomPostTypeService(client: client, blog: blog), postType: postType) + presentingViewController: self) { [blog, weak self] client in + PinnedPostTypeView( + blog: blog, + service: CustomPostTypeService(client: client, blog: blog), + postType: postType, + presentingViewController: self + ) } let controller = UIHostingController(rootView: rootView) controller.navigationItem.largeTitleDisplayMode = .never diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 1ac1d0216e4a..84147666e005 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -112,34 +112,8 @@ struct CustomPostListView: View { } message: { _ in Text(Strings.trashConfirmationMessage) } - .sheet(item: $viewModel.menuNavigation) { - menuNavigationDestination($0) - } } - @ViewBuilder - private func menuNavigationDestination(_ navigation: CustomPostListViewModel.PostMenuNavigation) -> some View { - switch navigation { - case .stats(let post): - StatsRepresentable( - postID: Int(post.id), - postTitle: post.title?.raw, - postURL: URL(string: post.link) - ) - case .comments(let post, let siteID): - CommentsRepresentable(postID: post.id, siteID: siteID) - case .blaze(let post): - BlazeRepresentable(postID: post.id, blog: viewModel.blog) - case .settings(let post): - SettingsRepresentable( - post: post, - blog: viewModel.blog, - client: viewModel.client, - service: viewModel.postService, - details: details - ) - } - } } private struct PaginatedList: View { @@ -371,7 +345,7 @@ private struct PostActionMenuContent: View { private var navigationSection: some View { Section { ForEach(viewModel.navigationMenuItems(for: post)) { navigation in - Button(action: { viewModel.menuNavigation = navigation }) { + Button(action: { viewModel.handleMenuNavigation(navigation) }) { Label(navigation.label, systemImage: navigation.systemImage) } } @@ -473,78 +447,6 @@ private struct ErrorRow: View { } } -// MARK: - UIViewControllerRepresentable - -private struct StatsRepresentable: UIViewControllerRepresentable { - let postID: Int - let postTitle: String? - let postURL: URL? - - func makeUIViewController(context: Context) -> UIViewController { - let statsVC = PostStatsTableViewController.withJPBannerForBlog( - postID: postID, - postTitle: postTitle, - postURL: postURL - ) - return UINavigationController(rootViewController: statsVC) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -private struct CommentsRepresentable: UIViewControllerRepresentable { - let postID: Int64 - let siteID: NSNumber - - func makeUIViewController(context: Context) -> UIViewController { - let commentsVC = ReaderCommentsViewController( - postID: NSNumber(value: postID), - siteID: siteID - ) - return UINavigationController(rootViewController: commentsVC) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -private struct BlazeRepresentable: UIViewControllerRepresentable { - let postID: Int64 - let blog: Blog - - func makeUIViewController(context: Context) -> UINavigationController { - BlazeFlowCoordinator.makeBlazeWebViewController( - source: .postsList, - blog: blog, - postID: NSNumber(value: postID) - ) - } - - func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} -} - -private struct SettingsRepresentable: UIViewControllerRepresentable { - let post: AnyPostWithEditContext - let blog: Blog - let client: WordPressClient - let service: WordPressAPIInternal.PostService - let details: PostTypeDetailsWithEditContext - - func makeUIViewController(context: Context) -> UIViewController { - let editorService = CustomPostEditorService( - blog: blog, - post: post, - details: details, - client: client, - service: service - ) - let viewModel = PostSettingsViewModel(editorService: editorService, blog: blog, isStandalone: true) - let settingsVC = PostSettingsViewController(viewModel: viewModel) - return UINavigationController(rootViewController: settingsVC) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - private enum Strings { static let emptyStateMessage = NSLocalizedString( "customPostList.emptyState.message", diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index d96c957d190d..b56947ea823b 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -15,6 +15,7 @@ final class CustomPostListViewModel: ObservableObject { private let details: PostTypeDetailsWithEditContext let blog: Blog let filter: CustomPostListFilter + weak var presentingViewController: UIViewController? private var collection: PostMetadataCollectionWithEditContext @@ -23,7 +24,6 @@ final class CustomPostListViewModel: ObservableObject { @Published private var error: Error? @Published var postToDelete: AnyPostWithEditContext? @Published var postToTrash: AnyPostWithEditContext? - @Published var menuNavigation: PostMenuNavigation? @Published var progressHUDState: ProgressHUDState = .idle var shouldDisplayEmptyView: Bool { @@ -47,7 +47,8 @@ final class CustomPostListViewModel: ObservableObject { service: WpService, details: PostTypeDetailsWithEditContext, filter: CustomPostListFilter, - blog: Blog + blog: Blog, + presentingViewController: UIViewController? = nil ) { self.client = client self.service = service @@ -55,6 +56,7 @@ final class CustomPostListViewModel: ObservableObject { self.details = details self.blog = blog self.filter = filter + self.presentingViewController = presentingViewController collection = service .posts() @@ -180,23 +182,63 @@ final class CustomPostListViewModel: ObservableObject { return items } + func handleMenuNavigation(_ navigation: PostMenuNavigation) { + guard let vc = presentingViewController else { return } + + switch navigation { + case .stats(let post): + let statsVC = PostStatsTableViewController.withJPBannerForBlog( + postID: Int(post.id), + postTitle: post.title?.raw, + postURL: URL(string: post.link) + ) + vc.navigationController?.pushViewController(statsVC, animated: true) + + case .comments(let post, let siteID): + let commentsVC = ReaderCommentsViewController( + postID: NSNumber(value: post.id), + siteID: siteID + ) + vc.navigationController?.pushViewController(commentsVC, animated: true) + + case .blaze(let post): + BlazeFlowCoordinator.presentBlazeWebFlow( + in: vc, + source: .postsList, + blog: blog, + postID: NSNumber(value: post.id) + ) + + case .settings(let post): + let editorService = CustomPostEditorService( + blog: blog, + post: post, + details: details, + client: client, + service: service.posts() + ) + let viewModel = PostSettingsViewModel(editorService: editorService, blog: blog, isStandalone: true) + let settingsVC = PostSettingsViewController(viewModel: viewModel) + let nav = UINavigationController(rootViewController: settingsVC) + vc.present(nav, animated: true) + } + } + func menuNavigation(forBlaze post: AnyPostWithEditContext) -> PostMenuNavigation? { guard endpoint == .posts && BlazeHelper.isBlazeFlagEnabled() && blog.canBlaze - && post.status == .publish && post.password == nil else { return nil } + && post.status == .publish && (post.password ?? "") == "" else { return nil } return .blaze(post: post) } func menuNavigation(forStats post: AnyPostWithEditContext) -> PostMenuNavigation? { guard endpoint == .posts - && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() && blog.supports(.stats) && post.status == .publish else { return nil } return .stats(post: post) } func menuNavigation(forComments post: AnyPostWithEditContext) -> PostMenuNavigation? { guard details.supports.supports(feature: .comments) - && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() && post.status == .publish, let siteID = blog.dotComID else { return nil } return .comments(post: post, siteID: siteID) } @@ -253,12 +295,7 @@ extension CustomPostListViewModel { case settings(post: AnyPostWithEditContext) var id: String { - switch self { - case .stats(let post): return "stats-\(post.id)" - case .comments(let post, let siteId): return "site-\(siteId)-comments-\(post.id)" - case .blaze(let post): return "blaze-\(post.id)" - case .settings(let post): return "settings-\(post.id)" - } + label } var label: String { diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift index 324f58f2ae1f..0d79da58088c 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostSearchResultView.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit import WordPressAPI import WordPressAPIInternal import WordPressCore @@ -11,6 +12,7 @@ struct CustomPostSearchResultView: View { let service: WpService let details: PostTypeDetailsWithEditContext @Binding var searchText: String + weak var presentingViewController: UIViewController? let onSelectPost: (AnyPostWithEditContext) -> Void @State private var finalSearchText = "" @@ -22,7 +24,8 @@ struct CustomPostSearchResultView: View { service: service, details: details, filter: .search(input: finalSearchText), - blog: blog + blog: blog, + presentingViewController: presentingViewController ), details: details, client: client, diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift index 74795bb4a888..27814a59c48c 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -13,6 +13,7 @@ struct CustomPostTabView: View { let service: WpService let details: PostTypeDetailsWithEditContext let blog: Blog + weak var presentingViewController: UIViewController? @State private var selectedTab: CustomPostTab = .all @State private var searchText = "" @@ -43,47 +44,54 @@ struct CustomPostTabView: View { client: WordPressClient, service: WpService, details: PostTypeDetailsWithEditContext, - blog: Blog + blog: Blog, + presentingViewController: UIViewController? = nil ) { self.client = client self.service = service self.details = details self.blog = blog + self.presentingViewController = presentingViewController _allViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, filter: CustomPostListFilter(tab: .all), - blog: blog + blog: blog, + presentingViewController: presentingViewController )) _publishedViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, filter: CustomPostListFilter(tab: .published), - blog: blog + blog: blog, + presentingViewController: presentingViewController )) _draftsViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, filter: CustomPostListFilter(tab: .drafts), - blog: blog + blog: blog, + presentingViewController: presentingViewController )) _scheduledViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, filter: CustomPostListFilter(tab: .scheduled), - blog: blog + blog: blog, + presentingViewController: presentingViewController )) _trashViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, details: details, filter: CustomPostListFilter(tab: .trash), - blog: blog + blog: blog, + presentingViewController: presentingViewController )) } @@ -105,6 +113,7 @@ struct CustomPostTabView: View { service: service, details: details, searchText: $searchText, + presentingViewController: presentingViewController, onSelectPost: { editorPresentation = .editPost($0) } ) } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index f8728d0c9933..5f554d638703 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit import WordPressCore import WordPressData import WordPressAPI @@ -14,6 +15,7 @@ struct CustomPostTypesView: View { let blog: Blog let service: CustomPostTypeService + weak var presentingViewController: UIViewController? @State private var types: [PostTypeDetailsWithEditContext] = [] @State private var isLoading: Bool = true @@ -22,9 +24,10 @@ struct CustomPostTypesView: View { @SiteStorage private var pinnedTypes: [PinnedPostType] - init(blog: Blog, service: CustomPostTypeService) { + init(blog: Blog, service: CustomPostTypeService, presentingViewController: UIViewController? = nil) { self.blog = blog self.service = service + self.presentingViewController = presentingViewController _pinnedTypes = .pinnedPostTypes(for: service.blog) } @@ -106,7 +109,7 @@ struct CustomPostTypesView: View { let isPinned = pinnedTypes.contains { $0.slug == details.slug } return NavigationLink { if let wpService = service.wpService { - CustomPostTabView(client: service.client, service: wpService, details: details, blog: blog) + CustomPostTabView(client: service.client, service: wpService, details: details, blog: blog, presentingViewController: presentingViewController) } else { let _ = wpAssertionFailure("Expected wpService to be available") } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift index 0c5c2a308622..3ac8bf1fab27 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import UIKit import WordPressCore import WordPressData import WordPressAPI @@ -10,6 +11,7 @@ struct PinnedPostTypeView: View { let blog: Blog let customPostTypeService: CustomPostTypeService let postType: PinnedPostType + weak var presentingViewController: UIViewController? @SiteStorage private var pinnedTypes: [PinnedPostType] @@ -18,17 +20,18 @@ struct PinnedPostTypeView: View { @State private var isLoading = true @State private var error: Error? - init(blog: Blog, service: CustomPostTypeService, postType: PinnedPostType) { + init(blog: Blog, service: CustomPostTypeService, postType: PinnedPostType, presentingViewController: UIViewController? = nil) { self.blog = blog self.customPostTypeService = service self.postType = postType + self.presentingViewController = presentingViewController _pinnedTypes = .pinnedPostTypes(for: TaggedManagedObjectID(blog)) } var body: some View { Group { if let details, let wpService = customPostTypeService.wpService { - CustomPostTabView(client: customPostTypeService.client, service: wpService, details: details, blog: blog) + CustomPostTabView(client: customPostTypeService.client, service: wpService, details: details, blog: blog, presentingViewController: presentingViewController) } else if isLoading { ProgressView() .progressViewStyle(.circular) From 2e7d3380cf248bd63f229ea22c6f9ed53ce6a518 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 14:31:12 +1300 Subject: [PATCH 7/8] Show New Stats from the more action --- .../CustomPostTypes/CustomPostListView.swift | 2 +- .../CustomPostListViewModel.swift | 29 ++++++++++---- .../Stats/PostStatsViewController.swift | 40 +++++++++++++------ 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 84147666e005..4df58a60a389 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -327,7 +327,7 @@ private struct PostActionMenuContent: View { if post.status != .draft { Button(action: { Task { await viewModel.moveToDraft(post) } }) { - Label(Strings.moveToDraft, systemImage: "pencil") + Label(Strings.moveToDraft, systemImage: "pencil.line") } } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index b56947ea823b..51f4bb1046c3 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -187,12 +187,25 @@ final class CustomPostListViewModel: ObservableObject { switch navigation { case .stats(let post): - let statsVC = PostStatsTableViewController.withJPBannerForBlog( - postID: Int(post.id), - postTitle: post.title?.raw, - postURL: URL(string: post.link) - ) - vc.navigationController?.pushViewController(statsVC, animated: true) + if FeatureFlag.newStats.enabled { + let statsVC = PostStatsViewController( + postID: Int(post.id), + postTitle: post.title?.raw ?? "", + postURL: URL(string: post.link), + postDate: post.dateGmt, + blog: blog + ) + let navController = UINavigationController(rootViewController: statsVC) + navController.modalPresentationStyle = .pageSheet + vc.present(navController, animated: true) + } else { + let statsVC = PostStatsTableViewController.withJPBannerForBlog( + postID: Int(post.id), + postTitle: post.title?.raw, + postURL: URL(string: post.link) + ) + vc.navigationController?.pushViewController(statsVC, animated: true) + } case .comments(let post, let siteID): let commentsVC = ReaderCommentsViewController( @@ -310,8 +323,8 @@ extension CustomPostListViewModel { var systemImage: String { switch self { case .blaze: return "flame" - case .stats: return "chart.bar" - case .comments: return "bubble.left" + case .stats: return "chart.line.uptrend.xyaxis" + case .comments: return "bubble.right" case .settings: return "gearshape" } } diff --git a/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift index ae60b4963389..ec4fcd7b97c3 100644 --- a/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift @@ -7,13 +7,35 @@ import WordPressShared /// View controller that displays post statistics using the new SwiftUI PostStatsView final class PostStatsViewController: UIViewController { - private let post: AbstractPost + private let postInfo: PostStatsView.PostInfo + private let blog: Blog - init(post: AbstractPost) { - self.post = post + init(postInfo: PostStatsView.PostInfo, blog: Blog) { + self.postInfo = postInfo + self.blog = blog super.init(nibName: nil, bundle: nil) } + convenience init(postID: Int, postTitle: String, postURL: URL?, postDate: Date?, blog: Blog) { + let info = PostStatsView.PostInfo( + title: postTitle, + postID: String(postID), + postURL: postURL, + date: postDate + ) + self.init(postInfo: info, blog: blog) + } + + convenience init(post: AbstractPost) { + let info = PostStatsView.PostInfo( + title: post.titleForDisplay(), + postID: String(post.postID?.intValue ?? 0), + postURL: post.permaLink.flatMap(URL.init), + date: post.dateCreated + ) + self.init(postInfo: info, blog: post.blog) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -29,18 +51,12 @@ final class PostStatsViewController: UIViewController { } private func setupStatsView() { - guard let context = StatsContext(blog: post.blog), - let postID = post.postID?.intValue else { + guard let context = StatsContext(blog: blog), + postInfo.postID != "0" else { return } - let info = PostStatsView.PostInfo( - title: post.titleForDisplay(), - postID: String(postID), - postURL: post.permaLink.flatMap(URL.init), - date: post.dateCreated - ) let statsView = PostStatsView.make( - post: info, + post: postInfo, context: context, router: StatsRouter(viewController: self) ) From 57dd9e4a68ddbd0c110b93676c9038d0dc32db1a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 10 Mar 2026 14:51:10 +1300 Subject: [PATCH 8/8] Show pre-publish sheet --- .../CustomPostTypes/CustomPostListView.swift | 2 +- .../CustomPostListViewModel.swift | 20 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 4df58a60a389..8bd749629312 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -320,7 +320,7 @@ private struct PostActionMenuContent: View { // FIXME: Preview requires Core Data preview infrastructure (PreviewNonceHandler, AbstractPost) if post.status == .draft || post.status == .pending { - Button(action: { Task { await viewModel.publishPost(post) } }) { + Button(action: { viewModel.publishPost(post) }) { Label(Strings.publish, systemImage: "paperplane") } } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 51f4bb1046c3..83168847077d 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -148,10 +148,22 @@ final class CustomPostListViewModel: ObservableObject { postToTrash = post } - func publishPost(_ post: AnyPostWithEditContext) async { - var params = PostUpdateParams(meta: nil) - params.status = .publish - await updatePost(post, params: params) + func publishPost(_ post: AnyPostWithEditContext) { + guard let vc = presentingViewController else { return } + + let editorService = CustomPostEditorService( + blog: blog, + post: post, + details: details, + client: client, + service: service.posts() + ) + PublishPostViewController.show( + editorService: editorService, + blog: blog, + from: vc, + completion: { _ in } + ) } func moveToDraft(_ post: AnyPostWithEditContext) async {