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/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 bb4b5bbd7f4d..8bd749629312 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,42 @@ 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) + } + .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) + } } + } private struct PaginatedList: View { + let viewModel: CustomPostListViewModel let items: [CustomPostCollectionItem] let onLoadNextPage: () async throws -> Void let client: WordPressClient? @@ -93,12 +129,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 +146,7 @@ private struct PaginatedList: View { } init( + viewModel: CustomPostListViewModel, items: [CustomPostCollectionItem], onLoadNextPage: @escaping () async throws -> Void, client: WordPressClient? = nil, @@ -115,6 +154,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 +173,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 +235,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 +267,13 @@ 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) + } case .stale(_, let post): PostContent(post: post, client: client, mediaHost: mediaHost) @@ -233,11 +281,103 @@ private struct ForEachContent: View { } } +private struct PostActionMenu: View { + let post: AnyPostWithEditContext + let viewModel: CustomPostListViewModel + + var body: some View { + Menu { + PostActionMenuContent(post: post, viewModel: viewModel) + } label: { + Image(systemName: "ellipsis") + .font(.body) + .tint(.secondary) + .frame(width: 28, height: 28) + .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 { + Section { + if post.status == .publish { + 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: { viewModel.publishPost(post) }) { + Label(Strings.publish, systemImage: "paperplane") + } + } + + if post.status != .draft { + Button(action: { Task { await viewModel.moveToDraft(post) } }) { + Label(Strings.moveToDraft, systemImage: "pencil.line") + } + } + + // 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.handleMenuNavigation(navigation) }) { + Label(navigation.label, systemImage: navigation.systemImage) + } + } + } + } + + @ViewBuilder + private var trashSection: some View { + Section { + if post.status != .trash { + Button(role: .destructive, action: { + if post.status == .publish { + viewModel.confirmTrash(post) + } else { + 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 +394,6 @@ private struct PostContent: View { Text(verbatim: post.headerBadges) .font(.footnote) .foregroundStyle(.secondary) - - Spacer() - - if showsEllipsisMenu { - ellipsisMenu - } } } @@ -295,21 +429,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 { @@ -334,124 +453,50 @@ private enum Strings { 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 trashConfirmationTitle = NSLocalizedString( + "customPostList.trashConfirmation.title", + value: "Move to Trash?", + comment: "Title for the confirmation alert when trashing a published 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 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?", + comment: "Title for the confirmation alert when permanently deleting a post" + ) + 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 +532,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..83168847077d 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,16 +9,22 @@ 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 + weak var presentingViewController: UIViewController? private var collection: PostMetadataCollectionWithEditContext @Published private(set) var items: [CustomPostCollectionItem] = [] @Published private(set) var listInfo: ListInfo? @Published private var error: Error? + @Published var postToDelete: AnyPostWithEditContext? + @Published var postToTrash: AnyPostWithEditContext? + @Published var progressHUDState: ProgressHUDState = .idle var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false @@ -27,6 +34,10 @@ final class CustomPostListViewModel: ObservableObject { items.isEmpty && listInfo?.isSyncing == true } + var postService: WordPressAPIInternal.PostService { + service.posts() + } + func errorToDisplay() -> Error? { items.isEmpty ? error : nil } @@ -34,14 +45,18 @@ final class CustomPostListViewModel: ObservableObject { init( client: WordPressClient, service: WpService, - endpoint: PostEndpointType, + details: PostTypeDetailsWithEditContext, filter: CustomPostListFilter, - blog: Blog + blog: Blog, + presentingViewController: UIViewController? = nil ) { self.client = client - self.endpoint = endpoint + self.service = service + self.endpoint = details.toPostEndpointType() + self.details = details self.blog = blog self.filter = filter + self.presentingViewController = presentingViewController collection = service .posts() @@ -56,7 +71,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) } } @@ -77,7 +92,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 } @@ -85,7 +100,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,20 +110,20 @@ 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) } + let items = try await collection.loadItems().map { CustomPostCollectionItem(item: $0, blog: blog, primaryStatus: filter.primaryStatus) } withAnimation { if self.listInfo != listInfo { self.listInfo = listInfo @@ -118,11 +133,174 @@ 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 confirmTrash(_ post: AnyPostWithEditContext) { + postToTrash = post + } + + 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 { + 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 handleMenuNavigation(_ navigation: PostMenuNavigation) { + guard let vc = presentingViewController else { return } + + switch navigation { + case .stats(let post): + 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( + 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 ?? "") == "" else { return nil } + return .blaze(post: post) + } + + func menuNavigation(forStats post: AnyPostWithEditContext) -> PostMenuNavigation? { + guard endpoint == .posts + && 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) + && 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 +311,39 @@ 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 { + label + } + + 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.line.uptrend.xyaxis" + case .comments: return "bubble.right" + case .settings: return "gearshape" + } + } + } + +} + struct CustomPostCollectionDisplayPost: Equatable { let date: Date let title: String? @@ -141,7 +352,7 @@ struct CustomPostCollectionDisplayPost: Equatable { let status: PostStatus let sticky: Bool let featuredMedia: MediaId? - let filterStatus: PostStatus? + let primaryStatus: PostStatus init( date: Date, @@ -151,7 +362,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 @@ -160,10 +371,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 @@ -185,7 +396,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. @@ -223,11 +434,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()) } @@ -252,14 +461,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 { @@ -304,18 +505,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) @@ -327,7 +528,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)) } } } @@ -342,3 +543,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..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 @@ -9,9 +10,9 @@ struct CustomPostSearchResultView: View { let blog: Blog let client: WordPressClient let service: WpService - let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext @Binding var searchText: String + weak var presentingViewController: UIViewController? let onSelectPost: (AnyPostWithEditContext) -> Void @State private var finalSearchText = "" @@ -21,9 +22,10 @@ struct CustomPostSearchResultView: View { viewModel: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter.default.with(search: finalSearchText), - blog: blog + details: details, + filter: .search(input: finalSearchText), + 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 b47143f160dc..27814a59c48c 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTabView.swift @@ -11,9 +11,9 @@ import DesignSystem struct CustomPostTabView: View { let client: WordPressClient let service: WpService - let endpoint: PostEndpointType let details: PostTypeDetailsWithEditContext let blog: Blog + weak var presentingViewController: UIViewController? @State private var selectedTab: CustomPostTab = .all @State private var searchText = "" @@ -43,50 +43,55 @@ struct CustomPostTabView: View { init( client: WordPressClient, service: WpService, - endpoint: PostEndpointType, details: PostTypeDetailsWithEditContext, - blog: Blog + blog: Blog, + presentingViewController: UIViewController? = nil ) { self.client = client self.service = service - self.endpoint = endpoint self.details = details self.blog = blog + self.presentingViewController = presentingViewController _allViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter(status: .custom("any")), - blog: blog + details: details, + filter: CustomPostListFilter(tab: .all), + blog: blog, + presentingViewController: presentingViewController )) _publishedViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter(status: .publish), - blog: blog + details: details, + filter: CustomPostListFilter(tab: .published), + blog: blog, + presentingViewController: presentingViewController )) _draftsViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter(status: .draft), - blog: blog + details: details, + filter: CustomPostListFilter(tab: .drafts), + blog: blog, + presentingViewController: presentingViewController )) _scheduledViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter(status: .future), - blog: blog + details: details, + filter: CustomPostListFilter(tab: .scheduled), + blog: blog, + presentingViewController: presentingViewController )) _trashViewModel = State(initialValue: CustomPostListViewModel( client: client, service: service, - endpoint: endpoint, - filter: CustomPostListFilter(status: .trash), - blog: blog + details: details, + filter: CustomPostListFilter(tab: .trash), + blog: blog, + presentingViewController: presentingViewController )) } @@ -106,9 +111,9 @@ struct CustomPostTabView: View { blog: blog, client: client, service: service, - endpoint: endpoint, details: details, searchText: $searchText, + presentingViewController: presentingViewController, onSelectPost: { editorPresentation = .editPost($0) } ) } @@ -179,15 +184,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/CustomPostTypesView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostTypesView.swift index 72c46d35f90a..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, endpoint: details.toPostEndpointType(), 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/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 ) } } diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/PinnedPostTypeView.swift index 02ad1efd74b6..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, endpoint: details.toPostEndpointType(), details: details, blog: blog) + CustomPostTabView(client: customPostTypeService.client, service: wpService, details: details, blog: blog, presentingViewController: presentingViewController) } 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 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) )