From 6e2870b8679c71c9a5ca6bc0a65f1c32a32a5458 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 15:36:36 +1200 Subject: [PATCH 1/7] Format files in preparation for page attribute actions --- .../CustomPostTypes/CustomPostListView.swift | 24 ++++++----- .../CustomPostListViewModel.swift | 40 ++++++++++++------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index 80936f8c2ed7..aba87bac0d15 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -80,7 +80,8 @@ struct CustomPostListView: View { ) .overlay { if viewModel.shouldDisplayEmptyView { - let emptyText = details.labels.notFound.isEmpty + let emptyText = + details.labels.notFound.isEmpty ? String.localizedStringWithFormat(Strings.emptyStateMessage, details.name) : details.labels.notFound EmptyStateView(emptyText, systemImage: "doc.text") @@ -307,7 +308,8 @@ private struct PaginatedList: View { Text(verbatim: SharedStrings.Button.retry) } .buttonStyle(.borderedProminent) - }.frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, alignment: .center) } } } @@ -538,13 +540,16 @@ private struct PostActionMenuContent: View { 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) } + Button( + role: .destructive, + action: { + if post.status == .publish { + viewModel.confirmTrash(post) + } else { + Task { await viewModel.trashPost(post) } + } } - }) { + ) { Label(Strings.moveToTrash, systemImage: "trash") } } else { @@ -646,7 +651,8 @@ 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')." + comment: + "Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')." ) static let homepageBadge = NSLocalizedString( "customPostList.badge.homepage", diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index ee30398b76d5..e2893a6bdfbc 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -76,7 +76,8 @@ final class CustomPostListViewModel: ObservableObject { self.showsHierarchyIfApplicable = showsHierarchyIfApplicable self.presentingViewController = presentingViewController - collection = service + collection = + service .posts() .createPostMetadataCollectionWithEditContext( endpointType: details.toPostEndpointType(), @@ -94,7 +95,8 @@ final class CustomPostListViewModel: ObservableObject { withAnimation { filter.author = author - collection = service + collection = + service .posts() .createPostMetadataCollectionWithEditContext( endpointType: endpoint, @@ -254,8 +256,9 @@ final class CustomPostListViewModel: ObservableObject { } if endpoint == .pages, - case .staticPage(let homepagePageID) = homepageSetting, - filter.statuses.contains(.publish) || filter.statuses.contains(.custom("any")) { + case .staticPage(let homepagePageID) = homepageSetting, + filter.statuses.contains(.publish) || filter.statuses.contains(.custom("any")) + { items.markHomepage(id: homepagePageID) } @@ -276,9 +279,11 @@ final class CustomPostListViewModel: ObservableObject { } let entries = PageTree.buildHierarchy(from: posts) - let itemMap = Dictionary(uniqueKeysWithValues: items.compactMap { item -> (Int64, CustomPostCollectionItem)? in - return (item.id, item) - }) + let itemMap = Dictionary( + uniqueKeysWithValues: items.compactMap { item -> (Int64, CustomPostCollectionItem)? in + (item.id, item) + } + ) indentationMap = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) self.items = entries.compactMap { itemMap[$0.id] } @@ -404,21 +409,27 @@ final class CustomPostListViewModel: ObservableObject { } func menuNavigation(forBlaze post: AnyPostWithEditContext) -> PostMenuNavigation? { - guard endpoint == .posts + guard + endpoint == .posts && BlazeHelper.isBlazeFlagEnabled() && blog.canBlaze - && post.status == .publish && (post.password ?? "").isEmpty else { return nil } + && post.status == .publish && (post.password ?? "").isEmpty + 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 } + 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 } + guard + details.supports.supports(feature: .comments) + && post.status == .publish, let siteID = blog.dotComID + else { return nil } return .comments(post: post, siteID: siteID) } @@ -551,7 +562,8 @@ struct CustomPostCollectionDisplayPost: Equatable { self.date = entity.dateGmt self.modifiedDate = entity.modifiedGmt self.title = entity.title?.raw - let contentPreview = GutenbergExcerptGenerator + let contentPreview = + GutenbergExcerptGenerator .firstParagraph(from: entity.content.rendered) .replacingOccurrences( of: "[\n]{2,}", From 91a28696e938e71ebddc727690016741ab909463 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 26 Mar 2026 22:12:38 +1300 Subject: [PATCH 2/7] Replace isHomepage with PageRole enum in custom post list Consolidate homepage and posts page tracking into a single PageRole enum. Update HomepageSetting to track both homepage and posts page IDs from site settings. --- .../CustomPostListViewModel.swift | 56 ++++++++++++----- .../Tests/CustomPostTypes/PageRoleTests.swift | 63 +++++++++++++++++++ 2 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 WordPress/Tests/CustomPostTypes/PageRoleTests.swift diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index e2893a6bdfbc..6ec2882be688 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -256,10 +256,10 @@ final class CustomPostListViewModel: ObservableObject { } if endpoint == .pages, - case .staticPage(let homepagePageID) = homepageSetting, + case .staticPage(let homepageID, let postsPageID) = homepageSetting, filter.statuses.contains(.publish) || filter.statuses.contains(.custom("any")) { - items.markHomepage(id: homepagePageID) + items.markPageRoles(homepageID: homepageID, postsPageID: postsPageID) } if let exclude { @@ -465,8 +465,11 @@ final class CustomPostListViewModel: ObservableObject { do { let settings = try await client.fetchSiteSettings() - if settings.showOnFront == "page", settings.pageOnFront > 0 { - homepageSetting = .staticPage(id: Int64(settings.pageOnFront)) + if settings.showOnFront == "page" { + homepageSetting = .staticPage( + homepageID: settings.pageOnFront > 0 ? Int64(settings.pageOnFront) : nil, + postsPageID: settings.pageForPosts > 0 ? Int64(settings.pageForPosts) : nil + ) } else { homepageSetting = .latestPosts } @@ -532,7 +535,10 @@ struct CustomPostCollectionDisplayPost: Equatable { let sticky: Bool let featuredMedia: MediaId? let primaryStatus: PostStatus - var isHomepage: Bool + var pageRole: PageRole? + + /// Bridge property for backward compatibility — remove when `homepageBadge` is replaced with `pageRoleBadge`. + var isHomepage: Bool { pageRole == .homepage } init( date: Date, @@ -544,7 +550,7 @@ struct CustomPostCollectionDisplayPost: Equatable { sticky: Bool = false, featuredMedia: MediaId? = nil, primaryStatus: PostStatus = .publish, - isHomepage: Bool = false + pageRole: PageRole? = nil ) { self.date = date self.modifiedDate = modifiedDate @@ -555,7 +561,7 @@ struct CustomPostCollectionDisplayPost: Equatable { self.sticky = sticky self.featuredMedia = featuredMedia self.primaryStatus = primaryStatus - self.isHomepage = isHomepage + self.pageRole = pageRole } init(_ entity: AnyPostWithEditContext, blog: Blog, primaryStatus: PostStatus = .publish) { @@ -580,7 +586,7 @@ struct CustomPostCollectionDisplayPost: Equatable { self.sticky = entity.sticky ?? false self.featuredMedia = entity.featuredMedia self.primaryStatus = primaryStatus - self.isHomepage = false + self.pageRole = nil } /// The title to display, with a placeholder for untitled posts. @@ -677,6 +683,11 @@ extension PostStatus { } } +enum PageRole: Equatable { + case homepage + case postsPage +} + struct CustomPostCollectionItem: Identifiable, Equatable { let id: Int64 var post: CustomPostCollectionDisplayPost? @@ -688,12 +699,12 @@ struct CustomPostCollectionItem: Identifiable, Equatable { case error(message: String) } - var isHomepage: Bool { + var pageRole: PageRole? { get { - post?.isHomepage ?? false + post?.pageRole } set { - post?.isHomepage = newValue + post?.pageRole = newValue } } @@ -722,15 +733,26 @@ struct CustomPostCollectionItem: Identifiable, Equatable { self.state = .error(message: error) } } + + #if DEBUG + /// Testing-only initializer. + init(id: Int64, post: CustomPostCollectionDisplayPost?, state: State) { + self.id = id + self.post = post + self.state = state + } + #endif } extension Array where Element == CustomPostCollectionItem { - /// Marks the homepage item with the `isHomepage` flag. - mutating func markHomepage(id: Int64) { - guard let homepageIndex = firstIndex(where: { $0.id == id }) else { - return + /// Marks items with their page roles based on the site's homepage settings. + mutating func markPageRoles(homepageID: Int64?, postsPageID: Int64?) { + if let homepageID, let index = firstIndex(where: { $0.id == homepageID }) { + self[index].pageRole = .homepage + } + if let postsPageID, let index = firstIndex(where: { $0.id == postsPageID }) { + self[index].pageRole = .postsPage } - self[homepageIndex].isHomepage = true } } @@ -800,7 +822,7 @@ private enum Strings { /// Represents the WordPress "Your homepage displays" setting. private enum HomepageSetting { case latestPosts - case staticPage(id: Int64) + case staticPage(homepageID: Int64?, postsPageID: Int64?) } private enum Constants { diff --git a/WordPress/Tests/CustomPostTypes/PageRoleTests.swift b/WordPress/Tests/CustomPostTypes/PageRoleTests.swift new file mode 100644 index 000000000000..2329d6000e62 --- /dev/null +++ b/WordPress/Tests/CustomPostTypes/PageRoleTests.swift @@ -0,0 +1,63 @@ +import Testing +@testable import WordPress + +@Suite("markPageRoles") +struct MarkPageRolesTests { + + private func makeItem(id: Int64) -> CustomPostCollectionItem { + let post = CustomPostCollectionDisplayPost( + date: Date(), + title: "Page \(id)", + content: nil, + status: .publish + ) + return CustomPostCollectionItem(id: id, post: post, state: .loading) + } + + @Test("marks homepage and posts page on separate items") + func marksHomepageAndPostsPage() { + var items = [makeItem(id: 1), makeItem(id: 2), makeItem(id: 3)] + items.markPageRoles(homepageID: 1, postsPageID: 2) + #expect(items[0].pageRole == .homepage) + #expect(items[1].pageRole == .postsPage) + #expect(items[2].pageRole == nil) + } + + @Test("nil IDs result in no roles assigned") + func nilIDs() { + var items = [makeItem(id: 1), makeItem(id: 2)] + items.markPageRoles(homepageID: nil, postsPageID: nil) + #expect(items[0].pageRole == nil) + #expect(items[1].pageRole == nil) + } + + @Test("only homepage ID provided") + func onlyHomepageID() { + var items = [makeItem(id: 1), makeItem(id: 2)] + items.markPageRoles(homepageID: 1, postsPageID: nil) + #expect(items[0].pageRole == .homepage) + #expect(items[1].pageRole == nil) + } + + @Test("only posts page ID provided") + func onlyPostsPageID() { + var items = [makeItem(id: 1), makeItem(id: 2)] + items.markPageRoles(homepageID: nil, postsPageID: 2) + #expect(items[0].pageRole == nil) + #expect(items[1].pageRole == .postsPage) + } + + @Test("ID not found in items does nothing") + func idNotFound() { + var items = [makeItem(id: 1)] + items.markPageRoles(homepageID: 99, postsPageID: 100) + #expect(items[0].pageRole == nil) + } + + @Test("same ID for both roles assigns postsPage (last-write wins)") + func sameIDForBothRoles() { + var items = [makeItem(id: 1)] + items.markPageRoles(homepageID: 1, postsPageID: 1) + #expect(items[0].pageRole == .postsPage) + } +} From 81e2aa733cf1bd84b193d985822aec54e96c07dc Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 26 Mar 2026 23:21:44 +1300 Subject: [PATCH 3/7] Add setAsHomepage/setAsPostsPage/setAsRegularPage actions Add view model methods that update site settings via the WordPress core REST API with optimistic local state updates and rollback on failure. --- .../CustomPostListViewModel.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 6ec2882be688..024061b7d246 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -39,6 +39,10 @@ final class CustomPostListViewModel: ObservableObject { /// The view uses this set to dim rows, show spinners, and disable interaction. @Published private(set) var pendingPostIDs: Set = [] + var isPages: Bool { + endpoint == .pages + } + var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false } @@ -51,6 +55,10 @@ final class CustomPostListViewModel: ObservableObject { service.posts() } + func pageRole(for post: AnyPostWithEditContext) -> PageRole? { + items.first(where: { $0.id == post.id })?.pageRole + } + func errorToDisplay() -> Error? { items.isEmpty ? error : nil } @@ -457,6 +465,97 @@ final class CustomPostListViewModel: ObservableObject { } } + func setAsHomepage(_ post: AnyPostWithEditContext) async { + await applyPageRoleChange(for: post, role: .homepage) + } + + func setAsPostsPage(_ post: AnyPostWithEditContext) async { + await applyPageRoleChange(for: post, role: .postsPage) + } + + func setAsRegularPage(_ post: AnyPostWithEditContext) async { + await applyPageRoleChange(for: post, role: nil) + } + + /// Updates the site's homepage / posts-page assignment so `post` takes on + /// `role`, or clears its role when `role` is `nil`. + /// + /// Performs an optimistic local update that is reverted if the server-side + /// settings update fails. + private func applyPageRoleChange(for post: AnyPostWithEditContext, role: PageRole?) async { + let previousSetting = homepageSetting + let postID = post.id + + let newSetting: HomepageSetting + let params: SiteSettingsUpdateParams + let successMessage: String + + switch role { + case .homepage: + var postsPageID: Int64? + var clearPostsPage = false + if case .staticPage(_, let prev) = previousSetting { + if prev == postID { + clearPostsPage = true + } else { + postsPageID = prev + } + } + newSetting = .staticPage(homepageID: postID, postsPageID: postsPageID) + params = SiteSettingsUpdateParams( + showOnFront: "page", + pageOnFront: UInt64(postID), + pageForPosts: clearPostsPage ? 0 : nil + ) + successMessage = Strings.setHomepageSuccess + + case .postsPage: + var homepageID: Int64? + var clearHomepage = false + if case .staticPage(let prev, _) = previousSetting { + if prev == postID { + clearHomepage = true + } else { + homepageID = prev + } + } + newSetting = .staticPage(homepageID: homepageID, postsPageID: postID) + params = SiteSettingsUpdateParams( + showOnFront: "page", + pageOnFront: clearHomepage ? 0 : nil, + pageForPosts: UInt64(postID) + ) + successMessage = Strings.setPostsPageSuccess + + case nil: + // The "set as regular page" action only makes sense when a static + // page is currently designated; the context menu surfaces it only + // for the posts page, but we still bail defensively if the site is + // on "latest posts" mode. + guard case .staticPage(let homepageID, _) = previousSetting else { return } + newSetting = .staticPage(homepageID: homepageID, postsPageID: nil) + params = SiteSettingsUpdateParams(pageForPosts: 0) + successMessage = Strings.setRegularPageSuccess + } + + guard !pendingPostIDs.contains(postID) else { return } + pendingPostIDs.insert(postID) + defer { pendingPostIDs.remove(postID) } + + homepageSetting = newSetting + reapplyPageRoles() + + do { + _ = try await client.api.siteSettings.update(params: params) + Notice(title: successMessage).post() + } catch { + Loggers.app.error("Failed to update page role: \(error)") + homepageSetting = previousSetting + reapplyPageRoles() + Notice(error: error).post() + } + } + /// Fetches homepage settings using the cached site settings from /// `WordPressClient` when the endpoint is `.pages` and the setting /// has not been resolved yet. @@ -478,6 +577,17 @@ final class CustomPostListViewModel: ObservableObject { } } + private func reapplyPageRoles() { + // Clear all existing page roles + for index in items.indices { + items[index].pageRole = nil + } + + if case .staticPage(let homepageID, let postsPageID) = homepageSetting { + items.markPageRoles(homepageID: homepageID, postsPageID: postsPageID) + } + } + private func show(error: Error) { // This particular error should be ignored. if case FetchError.StaleLoadMore = error { @@ -817,6 +927,21 @@ private enum Strings { value: "Settings", comment: "Menu action to open post settings" ) + static let setHomepageSuccess = NSLocalizedString( + "customPostList.action.setHomepage.success", + value: "Page successfully updated", + comment: "Notice shown after successfully setting a page as the homepage" + ) + static let setPostsPageSuccess = NSLocalizedString( + "customPostList.action.setPostsPage.success", + value: "Page successfully updated", + comment: "Notice shown after successfully setting a page as the posts page" + ) + static let setRegularPageSuccess = NSLocalizedString( + "customPostList.action.setRegularPage.success", + value: "Page successfully updated", + comment: "Notice shown after successfully setting a page as a regular page" + ) } /// Represents the WordPress "Your homepage displays" setting. From 8cf42779d67faa270e6cc65d87aca51460cc686a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 26 Mar 2026 23:34:34 +1300 Subject: [PATCH 4/7] Add Page Attributes submenu and posts page badge Show Set as Homepage, Set as Posts Page, and Set as Regular Page actions in a Page Attributes submenu for published pages. Add a "Posts page" badge alongside the existing "Homepage" badge using the consolidated pageRole property. --- .../CustomPostTypes/CustomPostListView.swift | 103 +++++++++++++++++- .../CustomPostListViewModel.swift | 3 - 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index aba87bac0d15..c8729e3ebc0e 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -356,7 +356,12 @@ private struct ForEachContent: View { } else if showsPostActions { button .contextMenu { - PostActionMenuContent(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate) + PostActionMenuContent( + post: fullPost, + pageRole: item.pageRole, + viewModel: viewModel, + onDuplicate: onDuplicate + ) } .swipeActions(edge: .leading) { if fullPost.status == .publish { @@ -394,7 +399,12 @@ private struct ForEachContent: View { } } .overlay(alignment: .topTrailing) { - PostActionMenu(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate) + PostActionMenu( + post: fullPost, + pageRole: item.pageRole, + viewModel: viewModel, + onDuplicate: onDuplicate + ) .offset(y: -6) } } else { @@ -461,12 +471,13 @@ private struct ForEachContentWithIndentation: View { private struct PostActionMenu: View { let post: AnyPostWithEditContext + let pageRole: PageRole? let viewModel: CustomPostListViewModel let onDuplicate: (AnyPostWithEditContext) -> Void var body: some View { Menu { - PostActionMenuContent(post: post, viewModel: viewModel, onDuplicate: onDuplicate) + PostActionMenuContent(post: post, pageRole: pageRole, viewModel: viewModel, onDuplicate: onDuplicate) } label: { Image(systemName: "ellipsis") .font(.body) @@ -479,15 +490,29 @@ private struct PostActionMenu: View { private struct PostActionMenuContent: View { let post: AnyPostWithEditContext + let pageRole: PageRole? let viewModel: CustomPostListViewModel let onDuplicate: (AnyPostWithEditContext) -> Void var body: some View { primarySection + pageAttributesSection navigationSection trashSection } + @ViewBuilder + private var pageAttributesSection: some View { + if viewModel.isPages, post.status == .publish { + PageAttributeMenuSection( + pageRole: pageRole, + onSetHomepage: { Task { await viewModel.setAsHomepage(post) } }, + onSetPostsPage: { Task { await viewModel.setAsPostsPage(post) } }, + onSetRegularPage: { Task { await viewModel.setAsRegularPage(post) } } + ) + } + } + @ViewBuilder private var primarySection: some View { Section { @@ -561,6 +586,37 @@ private struct PostActionMenuContent: View { } } +private struct PageAttributeMenuSection: View { + let pageRole: PageRole? + let onSetHomepage: () -> Void + let onSetPostsPage: () -> Void + let onSetRegularPage: () -> Void + + var body: some View { + Section { + Menu { + if pageRole != .homepage { + Button(action: onSetHomepage) { + Label(Strings.setHomepage, systemImage: "house") + } + } + if pageRole != .postsPage { + Button(action: onSetPostsPage) { + Label(Strings.setPostsPage, systemImage: "text.word.spacing") + } + } + if pageRole == .postsPage { + Button(action: onSetRegularPage) { + Label(Strings.setRegularPage, systemImage: "arrow.uturn.backward") + } + } + } label: { + Label(Strings.pageAttributes, systemImage: "doc") + } + } + } +} + private struct PostContent: View { let post: CustomPostCollectionDisplayPost let client: WordPressClient? @@ -571,7 +627,7 @@ private struct PostContent: View { header content footer - homepageBadge + pageRoleBadge } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -619,14 +675,24 @@ private struct PostContent: View { } @ViewBuilder - private var homepageBadge: some View { - if post.isHomepage { + private var pageRoleBadge: some View { + switch post.pageRole { + case .homepage: HStack(spacing: 2) { Image(systemName: "house.fill") Text(verbatim: Strings.homepageBadge) } .font(.footnote) .foregroundStyle(.secondary) + case .postsPage: + HStack(spacing: 2) { + Image(systemName: "paragraphsign") + Text(verbatim: Strings.postsPageBadge) + } + .font(.footnote) + .foregroundStyle(.secondary) + case nil: + EmptyView() } } } @@ -714,6 +780,31 @@ private enum Strings { value: "Delete", comment: "Short label for the swipe action to permanently delete a trashed post. Keep this translation short." ) + static let setHomepage = NSLocalizedString( + "customPostList.action.setHomepage", + value: "Set as Homepage", + comment: "Menu action to set a page as the site homepage" + ) + static let setPostsPage = NSLocalizedString( + "customPostList.action.setPostsPage", + value: "Set as Posts Page", + comment: "Menu action to set a page as the posts page" + ) + static let setRegularPage = NSLocalizedString( + "customPostList.action.setRegularPage", + value: "Set as Regular Page", + comment: "Menu action to remove the posts page designation from a page" + ) + static let pageAttributes = NSLocalizedString( + "customPostList.action.pageAttributes", + value: "Page Attributes", + comment: "Label for the page attributes submenu in the context menu" + ) + static let postsPageBadge = NSLocalizedString( + "customPostList.badge.postsPage", + value: "Posts page", + comment: "Badge label shown on the posts page row in the custom post list for pages" + ) } // MARK: - Previews diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 024061b7d246..b2cb7fc09bc2 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -647,9 +647,6 @@ struct CustomPostCollectionDisplayPost: Equatable { let primaryStatus: PostStatus var pageRole: PageRole? - /// Bridge property for backward compatibility — remove when `homepageBadge` is replaced with `pageRoleBadge`. - var isHomepage: Bool { pageRole == .homepage } - init( date: Date, modifiedDate: Date? = nil, From 997c2c1e772e040b67e1a318227dba8f4275d1c0 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Fri, 27 Mar 2026 10:05:00 +1300 Subject: [PATCH 5/7] Gate page attribute actions on manage_options capability Add currentUserCan(_:) to WordPressClient for checking user capabilities via the cached current user data. Only show the Page Attributes submenu when the user has the manage_options capability. --- .../WordPressCore/WordPressClient.swift | 11 +++++++++++ .../CustomPostTypes/CustomPostListView.swift | 2 +- .../CustomPostListViewModel.swift | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index 638c5e915412..b0d05fdd8d75 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -270,6 +270,17 @@ public actor WordPressClient { } } + /// Returns whether the current user has the specified capability. + /// + /// Uses the cached current user data, so this typically does not trigger a network request. + /// + /// - Parameter capability: The capability to check. + /// - Returns: `true` if the user has the capability, `false` otherwise. + public func currentUserCan(_ capability: UserCapability) async throws -> Bool { + let user = try await fetchCurrentUser() + return user.capabilities.hasCap(capability: capability) + } + /// Fetches the site settings, using the cached value if available. /// /// If the cached task has failed, creates a new task and retries the fetch. diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift index c8729e3ebc0e..c96943092193 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift @@ -503,7 +503,7 @@ private struct PostActionMenuContent: View { @ViewBuilder private var pageAttributesSection: some View { - if viewModel.isPages, post.status == .publish { + if viewModel.canChangePageAttributes, post.status == .publish { PageAttributeMenuSection( pageRole: pageRole, onSetHomepage: { Task { await viewModel.setAsHomepage(post) } }, diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index b2cb7fc09bc2..c5b026d4e006 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -24,6 +24,7 @@ final class CustomPostListViewModel: ObservableObject { private var collection: PostMetadataCollectionWithEditContext private var homepageSetting: HomepageSetting? + private var canManageOptions = false private var isBatchSyncing = false // Whether we should show the content in a hierarchy view. // true if the number of cached items or the total items return by the API @@ -43,6 +44,12 @@ final class CustomPostListViewModel: ObservableObject { endpoint == .pages } + /// Whether the "Page Attributes" submenu should be available. + /// Requires the `page` post type and `manage_options` capability. + var canChangePageAttributes: Bool { + isPages && canManageOptions + } + var shouldDisplayEmptyView: Bool { items.isEmpty && listInfo?.isSyncing == false } @@ -556,9 +563,9 @@ final class CustomPostListViewModel: ObservableObject { } } - /// Fetches homepage settings using the cached site settings from - /// `WordPressClient` when the endpoint is `.pages` and the setting - /// has not been resolved yet. + /// Fetches homepage settings and user capabilities using cached data from + /// `WordPressClient` when the endpoint is `.pages` and the settings + /// have not been resolved yet. private func fetchHomepageSettingsIfNeeded() async { guard endpoint == .pages, homepageSetting == nil else { return } @@ -575,6 +582,12 @@ final class CustomPostListViewModel: ObservableObject { } catch { Loggers.app.error("Failed to fetch site settings for homepage detection: \(error)") } + + do { + canManageOptions = try await client.currentUserCan(.manageOptions) + } catch { + Loggers.app.error("Failed to fetch user capabilities: \(error)") + } } private func reapplyPageRoles() { From bf9934e4bb73d652b83df5a78e24c34fc3c9ccd1 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 15:07:52 +1200 Subject: [PATCH 6/7] Refetch site settings on pull-to-refresh WordPressClient.fetchSiteSettings now takes a forceRefresh flag that bypasses the loadSiteSettingsTask cache and re-runs the network fetch. CustomPostListViewModel passes forceRefresh through from pullToRefresh, so the Pages list picks up homepage and posts-page assignments changed outside the app without requiring an app relaunch. --- .../Sources/WordPressCore/WordPressClient.swift | 9 ++++++++- .../CustomPostListViewModel.swift | 17 ++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/WordPressCore/WordPressClient.swift b/Modules/Sources/WordPressCore/WordPressClient.swift index b0d05fdd8d75..f3c27ded44dd 100644 --- a/Modules/Sources/WordPressCore/WordPressClient.swift +++ b/Modules/Sources/WordPressCore/WordPressClient.swift @@ -284,7 +284,14 @@ public actor WordPressClient { /// Fetches the site settings, using the cached value if available. /// /// If the cached task has failed, creates a new task and retries the fetch. - public func fetchSiteSettings() async throws -> SiteSettingsWithEditContext { + /// Pass `forceRefresh: true` to bypass the cache and refetch from the server — + /// callers should do this when they know the server-side settings may have changed + /// outside this client (e.g. on pull-to-refresh). + public func fetchSiteSettings(forceRefresh: Bool = false) async throws -> SiteSettingsWithEditContext { + if forceRefresh { + self.loadSiteSettingsTask = newSiteSettingsTask() + return try await self.loadSiteSettingsTask.value + } switch await self.loadSiteSettingsTask.result { case .success(let settings): return settings case .failure: diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index c5b026d4e006..4c67fedcd581 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -128,7 +128,7 @@ final class CustomPostListViewModel: ObservableObject { } private func refresh(pullToRefresh: Bool) async { - await fetchHomepageSettingsIfNeeded() + await fetchHomepageSettingsIfNeeded(forceRefresh: pullToRefresh) if !pullToRefresh { await loadCachedItems() @@ -563,14 +563,17 @@ final class CustomPostListViewModel: ObservableObject { } } - /// Fetches homepage settings and user capabilities using cached data from - /// `WordPressClient` when the endpoint is `.pages` and the settings - /// have not been resolved yet. - private func fetchHomepageSettingsIfNeeded() async { - guard endpoint == .pages, homepageSetting == nil else { return } + /// Fetches homepage settings and user capabilities when the endpoint is `.pages`. + /// + /// Uses the `WordPressClient` cache unless `forceRefresh` is `true`. Pass `true` + /// from pull-to-refresh paths so the list picks up settings changed elsewhere + /// (e.g. via the web admin or another device) without requiring an app relaunch. + private func fetchHomepageSettingsIfNeeded(forceRefresh: Bool = false) async { + guard endpoint == .pages else { return } + guard forceRefresh || homepageSetting == nil else { return } do { - let settings = try await client.fetchSiteSettings() + let settings = try await client.fetchSiteSettings(forceRefresh: forceRefresh) if settings.showOnFront == "page" { homepageSetting = .staticPage( homepageID: settings.pageOnFront > 0 ? Int64(settings.pageOnFront) : nil, From f12059d77e4c791b8f32e4d5064a821f0a1ee64b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 21 May 2026 15:08:04 +1200 Subject: [PATCH 7/7] Show page-role badges on the All tab The mark-page-roles guard checked for .custom("any"), a legacy form that predates the .any case wordpress-rs now exposes. The All tab uses .any, so the predicate never matched and the Homepage / Posts page badges never rendered there. --- .../ViewRelated/CustomPostTypes/CustomPostListViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift index 4c67fedcd581..84d63df8c6be 100644 --- a/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift +++ b/WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListViewModel.swift @@ -272,7 +272,7 @@ final class CustomPostListViewModel: ObservableObject { if endpoint == .pages, case .staticPage(let homepageID, let postsPageID) = homepageSetting, - filter.statuses.contains(.publish) || filter.statuses.contains(.custom("any")) + filter.statuses.contains(.publish) || filter.statuses.contains(.any) { items.markPageRoles(homepageID: homepageID, postsPageID: postsPageID) }