From 3830dba2334256401a5fd56dd418dd269d9d3d2a Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 22 Jun 2026 13:27:29 -0400 Subject: [PATCH 1/4] Add the Site Editor "VirtualHomepage" row to the new (RS) pages list The legacy pages list shows a block-theme homepage row that opens the Site Editor web view, gated by SiteEditorMVP + isBlockBasedTheme. The new RS pages list lacked it. This adds a third Virtual.Kind (SITE_EDITOR) behind the same gate, opening the Site Editor on tap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/ui/pagesrs/PageRsListEvent.kt | 6 ++ .../android/ui/pagesrs/PageRsListUiState.kt | 10 ++- .../android/ui/pagesrs/PageRsTreeBuilder.kt | 27 ++++++- .../android/ui/pagesrs/PagesRsListActivity.kt | 15 ++++ .../ui/pagesrs/PagesRsListViewModel.kt | 66 +++++++++++++++- .../android/ui/pagesrs/screens/PageRsRow.kt | 28 +++++-- .../ui/pagesrs/PageRsTreeBuilderTest.kt | 79 +++++++++++++++++++ .../ui/pagesrs/PagesRsListViewModelTest.kt | 6 ++ 8 files changed, 226 insertions(+), 11 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt index 2c3ee5a1059f..5aa630128b77 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt @@ -20,6 +20,12 @@ internal sealed interface PageRsListEvent { data class CopyPageUrl(val url: String) : PageRsListEvent + /** Opens the block-theme homepage in the Site Editor web view via WPWebViewActivity. */ + data class OpenSiteEditor( + val url: String, + val useWpComCredentials: Boolean + ) : PageRsListEvent + data class PromoteWithBlaze( val site: SiteModel, val page: PostModel diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt index 1416d5677dc9..680e1b2222b1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt @@ -73,10 +73,18 @@ internal sealed interface PageRsListItem { ) : PageRsListItem { override val stableKey: String get() = "virtual:$kind" - enum class Kind { HOMEPAGE, POSTS_PAGE } + // HOMEPAGE / POSTS_PAGE wrap a real assigned static page; SITE_EDITOR is the block-theme + // homepage, which has no backing page and opens the Site Editor web view on tap. + enum class Kind { HOMEPAGE, POSTS_PAGE, SITE_EDITOR } } } +/** + * Sentinel [PageRsUiModel.remotePageId] for the synthetic SITE_EDITOR virtual row, which has no real + * page behind it. Real remote page ids are always positive, so a negative value can't collide. + */ +internal const val SITE_EDITOR_PAGE_ID = -1L + internal enum class PageRsDisplayState { NORMAL, FETCHING_WITH_DATA, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilder.kt index 038b5d08f78d..abe67d5eac51 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilder.kt @@ -10,6 +10,10 @@ internal const val MAX_INDENT_LEVEL = 3 * - When hierarchy is on, the pages identified by [pageOnFront] and [pageForPosts] * (when set and present in [pages]) are prepended as Virtual rows and hidden from * their normal sorted position. + * - When [showSiteEditorHomepage] is true (block-based theme + Site Editor MVP), a single + * SITE_EDITOR virtual row is prepended in place of the static HOMEPAGE row, and the + * [pageOnFront] page (if any) is still hidden from the tree. This mirrors the legacy list, + * where a block-theme homepage opens the Site Editor instead of the block editor. * - When [applyHierarchy] is false, [pages] are wrapped as flat Real rows with * indentLevel = 0 and no virtuals. */ @@ -17,7 +21,8 @@ internal fun buildRows( pages: List, applyHierarchy: Boolean, pageOnFront: Long, - pageForPosts: Long + pageForPosts: Long, + showSiteEditorHomepage: Boolean = false ): List { if (!applyHierarchy) { return pages.map { PageRsListItem.Real(it) } @@ -29,12 +34,30 @@ internal fun buildRows( val visible = pages.filterNot { it.remotePageId in hiddenIds } val tree = flattenToTree(visible) return buildList { - homepage?.let { add(PageRsListItem.Virtual(PageRsListItem.Virtual.Kind.HOMEPAGE, it)) } + if (showSiteEditorHomepage) { + add(siteEditorHomepageRow()) + } else { + homepage?.let { add(PageRsListItem.Virtual(PageRsListItem.Virtual.Kind.HOMEPAGE, it)) } + } postsPage?.let { add(PageRsListItem.Virtual(PageRsListItem.Virtual.Kind.POSTS_PAGE, it)) } addAll(tree) } } +/** + * The synthetic SITE_EDITOR virtual row. It has no real page, so its visible text is rendered from + * string resources in the composable; the sentinel id lets the tap handler recognize it. + */ +private fun siteEditorHomepageRow() = PageRsListItem.Virtual( + kind = PageRsListItem.Virtual.Kind.SITE_EDITOR, + page = PageRsUiModel( + remotePageId = SITE_EDITOR_PAGE_ID, + title = "", + excerpt = "", + date = "" + ) +) + internal fun flattenToTree(pages: List): List { val byId = pages.associateBy { it.remotePageId } val childrenByParent = pages diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt index 6a8fab57aef1..78a85f670962 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt @@ -18,6 +18,7 @@ import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.PagePostCreationSourcesDetail.PAGE_FROM_PAGES_LIST +import org.wordpress.android.ui.WPWebViewActivity import org.wordpress.android.ui.blaze.BlazeFlowSource import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper @@ -30,6 +31,7 @@ import org.wordpress.android.util.extensions.clipboardManager import org.wordpress.android.util.extensions.setContent import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel import org.wordpress.android.viewmodel.observeEvent +import org.wordpress.android.viewmodel.wpwebview.WPWebViewSource import javax.inject.Inject @AndroidEntryPoint @@ -109,6 +111,7 @@ class PagesRsListActivity : BaseAppCompatActivity() { is PageRsListEvent.SharePage -> ActivityLauncher.openShareIntent(this, event.url, event.title) is PageRsListEvent.CopyPageUrl -> copyUrlToClipboard(event.url) + is PageRsListEvent.OpenSiteEditor -> openSiteEditor(event.url, event.useWpComCredentials) is PageRsListEvent.PromoteWithBlaze -> ActivityLauncher.openPromoteWithBlaze(this, event.page, BlazeFlowSource.PAGES_LIST) is PageRsListEvent.ShowToast -> ToastUtils.showToast(this, event.messageResId) @@ -116,6 +119,18 @@ class PagesRsListActivity : BaseAppCompatActivity() { } } + private fun openSiteEditor(url: String, useWpComCredentials: Boolean) { + if (useWpComCredentials) { + WPWebViewActivity.openUrlByUsingGlobalWPCOMCredentials( + this, + url, + WPWebViewSource.PAGE_LIST_EDIT_HOMEPAGE + ) + } else { + WPWebViewActivity.openURL(this, url, WPWebViewSource.PAGE_LIST_EDIT_HOMEPAGE) + } + } + private fun copyUrlToClipboard(url: String) { clipboardManager?.setPrimaryClip(ClipData.newPlainText(CLIPBOARD_URL_LABEL, url)) // Android 13+ shows its own confirmation UI when the clipboard changes. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt index f5f207e8e967..8b071299acff 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt @@ -28,14 +28,19 @@ import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.EditorThemeActionBuilder import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.post.PostStatus as FluxCPostStatus import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.EditorThemeStore +import org.wordpress.android.fluxc.store.EditorThemeStore.FetchEditorThemePayload +import org.wordpress.android.fluxc.store.EditorThemeStore.OnEditorThemeChanged import org.wordpress.android.fluxc.store.PostStore import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded import org.wordpress.android.ui.blaze.BlazeFeatureUtils import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.pages.PageItem import org.wordpress.android.ui.posts.AuthorFilterSelection import org.wordpress.android.ui.postsrs.PostRsErrorUtils import org.wordpress.android.ui.postsrs.SnackbarMessage @@ -45,6 +50,7 @@ import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.util.config.SiteEditorMVPFeatureConfig import org.wordpress.android.viewmodel.ResourceProvider import rs.wordpress.cache.kotlin.ObservableMetadataCollection import rs.wordpress.cache.kotlin.getObservablePostMetadataCollectionWithEditContext @@ -74,6 +80,8 @@ internal class PagesRsListViewModel @Inject constructor( private val accountStore: AccountStore, private val appPrefsWrapper: AppPrefsWrapper, private val analyticsTracker: AnalyticsTrackerWrapper, + private val editorThemeStore: EditorThemeStore, + private val siteEditorMVPFeatureConfig: SiteEditorMVPFeatureConfig, ) : ViewModel() { private val _tabStates = MutableStateFlow>(emptyMap()) val tabStates: StateFlow> = _tabStates.asStateFlow() @@ -133,12 +141,18 @@ internal class PagesRsListViewModel @Inject constructor( ) val authorFilter: StateFlow = _authorFilter.asStateFlow() + // Whether the site's homepage uses a block-based theme. Seeded from the local cache and kept + // current via [onEditorThemeChanged]. When true (and the Site Editor MVP flag is on) the + // published tab shows a single SITE_EDITOR virtual row that opens the Site Editor web view. + private var isBlockBasedTheme = false + init { dispatcher.register(this) if (site == null) { _events.trySend(PageRsListEvent.ShowToast(R.string.blog_not_found)) _events.trySend(PageRsListEvent.Finish) } else { + refreshEditorTheme(site) @OptIn(FlowPreview::class) viewModelScope.launch { _searchQuery @@ -350,6 +364,30 @@ internal class PagesRsListViewModel @Inject constructor( refreshAllTabs() } + /** Seeds [isBlockBasedTheme] from the local cache and dispatches a remote refresh. */ + private fun refreshEditorTheme(site: SiteModel) { + isBlockBasedTheme = editorThemeStore.getIsBlockBasedTheme(site) + dispatcher.dispatch( + EditorThemeActionBuilder.newFetchEditorThemeAction( + FetchEditorThemePayload(site, gssEnabled = true) + ) + ) + } + + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + fun onEditorThemeChanged(event: OnEditorThemeChanged) { + val site = this.site ?: return + if (site.id != event.siteId) return + val isBlockBased = event.editorTheme?.themeSupport?.isEditorThemeBlockBased() ?: return + if (isBlockBased == isBlockBasedTheme) return + isBlockBasedTheme = isBlockBased + // Rebuild the published tab from cache so the SITE_EDITOR row appears/disappears. + if (collections.containsKey(PageRsListTab.PUBLISHED)) { + viewModelScope.launch { loadItemsForTab(PageRsListTab.PUBLISHED) } + } + } + /** Refreshes all currently initialized tabs. */ @MainThread fun refreshAllTabs() { @@ -453,11 +491,16 @@ internal class PagesRsListViewModel @Inject constructor( val site = this.site if (site == null || _isOpeningPage.value) return - val page = _tabStates.value[tab] + val item = _tabStates.value[tab] ?.pages ?.firstOrNull { it.remotePageId == remotePageId } - ?.page + if (item is PageRsListItem.Virtual && item.kind == PageRsListItem.Virtual.Kind.SITE_EDITOR) { + openSiteEditor(site) + return + } + + val page = item?.page when { tab == PageRsListTab.TRASHED || page?.isTrashed == true -> _pendingConfirmation.value = PageRsListConfirmation.MoveToDraft(remotePageId) @@ -465,6 +508,17 @@ internal class PagesRsListViewModel @Inject constructor( } } + /** Opens the block-theme homepage in the Site Editor web view, matching the legacy pages list. */ + private fun openSiteEditor(site: SiteModel) { + val useWpComCredentials = site.isWPCom || site.isWPComAtomic || site.isPrivateWPComAtomic + _events.trySend( + PageRsListEvent.OpenSiteEditor( + url = PageItem.VirtualHomepage.Action.OpenSiteEditor.getUrl(site), + useWpComCredentials = useWpComCredentials + ) + ) + } + private fun proceedOpenPage(site: SiteModel, remotePageId: Long, lastModified: String?) { analyticsTracker.track( Stat.PAGES_LIST_ITEM_SELECTED, @@ -1111,11 +1165,13 @@ internal class PagesRsListViewModel @Inject constructor( // and the construction-time [site] snapshot would pin stale pageOnFront / // pageForPosts values onto the virtual rows. val currentSite = selectedSiteRepository.getSelectedSite() ?: site + val showSiteEditorHomepage = siteEditorMVPFeatureConfig.isEnabled() && isBlockBasedTheme val rows = buildRows( pages = uiModels, applyHierarchy = applyHierarchy, pageOnFront = currentSite?.pageOnFront ?: 0L, - pageForPosts = currentSite?.pageForPosts ?: 0L + pageForPosts = currentSite?.pageForPosts ?: 0L, + showSiteEditorHomepage = showSiteEditorHomepage ).map { row -> row.withMenuActions(currentSite) } updateTabUiState(tab) { copy(pages = rows, isLoading = false, error = null, isAuthError = false) @@ -1189,6 +1245,10 @@ internal class PagesRsListViewModel @Inject constructor( } private fun PageRsListItem.withMenuActions(site: SiteModel?): PageRsListItem { + // The SITE_EDITOR virtual has no backing page, so it gets no overflow menu. + if (this is PageRsListItem.Virtual && kind == PageRsListItem.Virtual.Kind.SITE_EDITOR) { + return this + } val pageOnFront = site?.pageOnFront ?: 0L val pageForPosts = site?.pageForPosts ?: 0L // WP.com capabilities and showOnFront are synced reliably, so the homepage actions diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt index 0d93f9ac96cc..9caff85cef89 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt @@ -115,7 +115,22 @@ private fun PageContentItem( Spacer(modifier = Modifier.width(12.dp)) } Column(modifier = Modifier.weight(1f)) { - val virtualLabel = virtualKind?.let { stringResource(it.labelResId()) } + // The SITE_EDITOR row has no backing page, so its title/subtitle come from string + // resources and it shows no header label, badges, or excerpt. + val isSiteEditor = virtualKind == PageRsListItem.Virtual.Kind.SITE_EDITOR + val titleText = if (isSiteEditor) { + stringResource(R.string.virtual_homepage_title) + } else { + page.title.ifBlank { stringResource(R.string.untitled_in_parentheses) } + } + val subtitleText = if (isSiteEditor) { + stringResource(R.string.virtual_homepage_subtitle) + } else { + page.excerpt + } + val virtualLabel = virtualKind + ?.takeUnless { isSiteEditor } + ?.let { stringResource(it.labelResId()) } val statusLabel = page.statusLabelResId.takeIf { it != 0 }?.let { stringResource(it) } val bullet = stringResource(R.string.bullet_with_spaces) val headerText = listOfNotNull( @@ -137,15 +152,15 @@ private fun PageContentItem( Spacer(modifier = Modifier.height(4.dp)) } Text( - text = page.title.ifBlank { stringResource(R.string.untitled_in_parentheses) }, + text = titleText, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (page.excerpt.isNotBlank()) { + if (subtitleText.isNotBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = page.excerpt, + text = subtitleText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -283,13 +298,16 @@ private fun ErrorItem(modifier: Modifier = Modifier) { } private fun PageRsListItem.Virtual.Kind.icon(): ImageVector = when (this) { - PageRsListItem.Virtual.Kind.HOMEPAGE -> Icons.Filled.Home + PageRsListItem.Virtual.Kind.HOMEPAGE, + PageRsListItem.Virtual.Kind.SITE_EDITOR -> Icons.Filled.Home PageRsListItem.Virtual.Kind.POSTS_PAGE -> Icons.AutoMirrored.Filled.Article } +// SITE_EDITOR renders its own title from string resources and has no header label. private fun PageRsListItem.Virtual.Kind.labelResId(): Int = when (this) { PageRsListItem.Virtual.Kind.HOMEPAGE -> R.string.site_settings_homepage PageRsListItem.Virtual.Kind.POSTS_PAGE -> R.string.site_settings_posts_page + PageRsListItem.Virtual.Kind.SITE_EDITOR -> R.string.virtual_homepage_title } private const val INDENT_STEP_DP = 16 diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilderTest.kt index 4dc626244712..178f4dda88ed 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsTreeBuilderTest.kt @@ -167,6 +167,85 @@ class PageRsTreeBuilderTest { .containsOnly(0) } + @Test + fun `showSiteEditorHomepage prepends a single SITE_EDITOR virtual`() { + val pages = listOf(page(1), page(2), page(3)) + + val rows = buildRows( + pages, + applyHierarchy = true, + pageOnFront = 0L, + pageForPosts = 0L, + showSiteEditorHomepage = true + ) + + assertThat(rows).hasSize(4) + assertThat(rows[0]).isInstanceOfSatisfying(PageRsListItem.Virtual::class.java) { virtual -> + assertThat(virtual.kind).isEqualTo(PageRsListItem.Virtual.Kind.SITE_EDITOR) + assertThat(virtual.page.remotePageId).isEqualTo(SITE_EDITOR_PAGE_ID) + } + assertThat(rows.drop(1).map { (it as PageRsListItem.Real).page.remotePageId }) + .containsExactly(1L, 2L, 3L) + } + + @Test + fun `showSiteEditorHomepage replaces the static HOMEPAGE row and hides its page`() { + val pages = listOf(page(1), page(2), page(3)) + + val rows = buildRows( + pages, + applyHierarchy = true, + pageOnFront = 2L, + pageForPosts = 0L, + showSiteEditorHomepage = true + ) + + // SITE_EDITOR is shown instead of the static HOMEPAGE virtual, and page 2 is hidden. + assertThat(rows).hasSize(3) + assertThat((rows[0] as PageRsListItem.Virtual).kind) + .isEqualTo(PageRsListItem.Virtual.Kind.SITE_EDITOR) + assertThat(rows.drop(1).map { (it as PageRsListItem.Real).page.remotePageId }) + .containsExactly(1L, 3L) + } + + @Test + fun `showSiteEditorHomepage still shows the POSTS_PAGE virtual after SITE_EDITOR`() { + val pages = listOf(page(1), page(2), page(3)) + + val rows = buildRows( + pages, + applyHierarchy = true, + pageOnFront = 0L, + pageForPosts = 3L, + showSiteEditorHomepage = true + ) + + assertThat((rows[0] as PageRsListItem.Virtual).kind) + .isEqualTo(PageRsListItem.Virtual.Kind.SITE_EDITOR) + assertThat((rows[1] as PageRsListItem.Virtual).kind) + .isEqualTo(PageRsListItem.Virtual.Kind.POSTS_PAGE) + assertThat((rows[1] as PageRsListItem.Virtual).page.remotePageId).isEqualTo(3L) + assertThat(rows.drop(2).map { (it as PageRsListItem.Real).page.remotePageId }) + .containsExactly(1L, 2L) + } + + @Test + fun `showSiteEditorHomepage is ignored when applyHierarchy is false`() { + val pages = listOf(page(1), page(2)) + + val rows = buildRows( + pages, + applyHierarchy = false, + pageOnFront = 0L, + pageForPosts = 0L, + showSiteEditorHomepage = true + ) + + assertThat(rows).allMatch { it is PageRsListItem.Real } + assertThat(rows.map { (it as PageRsListItem.Real).page.remotePageId }) + .containsExactly(1L, 2L) + } + @Test fun `self-parented page is appended as a flat row instead of dropped`() { val pages = listOf( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt index 038a4ed9c820..18cc910da1fd 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt @@ -23,6 +23,7 @@ import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.EditorThemeStore import org.wordpress.android.fluxc.store.PostStore import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded import org.wordpress.android.ui.blaze.BlazeFeatureUtils @@ -33,6 +34,7 @@ import org.wordpress.android.ui.postsrs.data.WpServiceProvider import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.util.NetworkUtilsWrapper import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import org.wordpress.android.util.config.SiteEditorMVPFeatureConfig import org.wordpress.android.viewmodel.ResourceProvider @ExperimentalCoroutinesApi @@ -50,6 +52,8 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) @Mock lateinit var accountStore: AccountStore @Mock lateinit var appPrefsWrapper: AppPrefsWrapper @Mock lateinit var analyticsTracker: AnalyticsTrackerWrapper + @Mock lateinit var editorThemeStore: EditorThemeStore + @Mock lateinit var siteEditorMVPFeatureConfig: SiteEditorMVPFeatureConfig private lateinit var site: SiteModel private var activeViewModel: PagesRsListViewModel? = null @@ -84,6 +88,8 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) accountStore = accountStore, appPrefsWrapper = appPrefsWrapper, analyticsTracker = analyticsTracker, + editorThemeStore = editorThemeStore, + siteEditorMVPFeatureConfig = siteEditorMVPFeatureConfig, ).also { activeViewModel = it } @Test From f881fe5df4727b2d0028358b5b9ad4935312c554 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 22 Jun 2026 13:37:15 -0400 Subject: [PATCH 2/4] Skip the editor-theme fetch when the Site Editor MVP flag is off The block-theme state is only used to show the SITE_EDITOR row, so there's no reason to dispatch a FetchEditorTheme request on every pages-list visit for users who don't have the flag enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wordpress/android/ui/pagesrs/PagesRsListViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt index 8b071299acff..98b3733e7990 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt @@ -152,7 +152,11 @@ internal class PagesRsListViewModel @Inject constructor( _events.trySend(PageRsListEvent.ShowToast(R.string.blog_not_found)) _events.trySend(PageRsListEvent.Finish) } else { - refreshEditorTheme(site) + // Only the SITE_EDITOR virtual row needs the block-theme state, so skip the fetch + // entirely when the Site Editor MVP flag is off to avoid a request on every visit. + if (siteEditorMVPFeatureConfig.isEnabled()) { + refreshEditorTheme(site) + } @OptIn(FlowPreview::class) viewModelScope.launch { _searchQuery From 4b2226a7f0a1f3a8362a7e0fe2be0826a56f1d5f Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 22 Jun 2026 13:43:33 -0400 Subject: [PATCH 3/4] Harden the Site Editor row tap: debounce, analytics, and tests - Detect the row by its sentinel id instead of a tab-state lookup, so the tap works regardless of list state and is unit-testable. - Guard against a rapid double-tap launching two web views. - Track PAGES_EDIT_HOMEPAGE_ITEM_PRESSED on open, matching the legacy list. - Add ViewModel tests for the emitted event and analytics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/pagesrs/PagesRsListViewModel.kt | 26 ++++++++--- .../ui/pagesrs/PagesRsListViewModelTest.kt | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt index 98b3733e7990..4cdd2a9c5b24 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -146,6 +147,9 @@ internal class PagesRsListViewModel @Inject constructor( // published tab shows a single SITE_EDITOR virtual row that opens the Site Editor web view. private var isBlockBasedTheme = false + // Guards against a rapid double-tap on the SITE_EDITOR row launching two web views. + private var isLaunchingSiteEditor = false + init { dispatcher.register(this) if (site == null) { @@ -495,16 +499,15 @@ internal class PagesRsListViewModel @Inject constructor( val site = this.site if (site == null || _isOpeningPage.value) return - val item = _tabStates.value[tab] - ?.pages - ?.firstOrNull { it.remotePageId == remotePageId } - - if (item is PageRsListItem.Virtual && item.kind == PageRsListItem.Virtual.Kind.SITE_EDITOR) { + if (remotePageId == SITE_EDITOR_PAGE_ID) { openSiteEditor(site) return } - val page = item?.page + val page = _tabStates.value[tab] + ?.pages + ?.firstOrNull { it.remotePageId == remotePageId } + ?.page when { tab == PageRsListTab.TRASHED || page?.isTrashed == true -> _pendingConfirmation.value = PageRsListConfirmation.MoveToDraft(remotePageId) @@ -514,6 +517,9 @@ internal class PagesRsListViewModel @Inject constructor( /** Opens the block-theme homepage in the Site Editor web view, matching the legacy pages list. */ private fun openSiteEditor(site: SiteModel) { + if (isLaunchingSiteEditor) return + isLaunchingSiteEditor = true + analyticsTracker.track(Stat.PAGES_EDIT_HOMEPAGE_ITEM_PRESSED, site) val useWpComCredentials = site.isWPCom || site.isWPComAtomic || site.isPrivateWPComAtomic _events.trySend( PageRsListEvent.OpenSiteEditor( @@ -521,6 +527,13 @@ internal class PagesRsListViewModel @Inject constructor( useWpComCredentials = useWpComCredentials ) ) + // The web view opens in a separate activity with no completion callback, so clear the + // guard after a short debounce: a rapid double-tap is dropped, but the row stays tappable + // when the user returns. + viewModelScope.launch { + delay(SITE_EDITOR_LAUNCH_DEBOUNCE_MS) + isLaunchingSiteEditor = false + } } private fun proceedOpenPage(site: SiteModel, remotePageId: Long, lastModified: String?) { @@ -1392,6 +1405,7 @@ internal class PagesRsListViewModel @Inject constructor( companion object { private const val PAGE_SIZE = 20 private const val SEARCH_DEBOUNCE_MS = 250L + private const val SITE_EDITOR_LAUNCH_DEBOUNCE_MS = 1000L internal const val MIN_SEARCH_QUERY_LENGTH = 3 private const val THUMBNAIL_SIZE_DP = 64 private val ALL_STATUSES = PageRsListTab.entries.flatMap { it.statuses }.distinct() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt index 18cc910da1fd..0b1a58879f63 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt @@ -303,6 +303,49 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) ) } + @Test + fun `openPage on the site editor row emits OpenSiteEditor`() = test { + site.setIsWPCom(true) + val viewModel = createViewModel() + + viewModel.events.test { + viewModel.openPage(SITE_EDITOR_PAGE_ID, PageRsListTab.PUBLISHED) + + val event = awaitItem() + assertThat(event).isInstanceOf(PageRsListEvent.OpenSiteEditor::class.java) + event as PageRsListEvent.OpenSiteEditor + assertThat(event.url).endsWith("site-editor.php?canvas=edit") + assertThat(event.useWpComCredentials).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `openPage on the site editor row tracks PAGES_EDIT_HOMEPAGE_ITEM_PRESSED`() { + val viewModel = createViewModel() + + viewModel.openPage(SITE_EDITOR_PAGE_ID, PageRsListTab.PUBLISHED) + + verify(analyticsTracker).track( + eq(Stat.PAGES_EDIT_HOMEPAGE_ITEM_PRESSED), + eq(site), + anyOrNull>() + ) + } + + @Test + fun `openPage on the site editor row does not track PAGES_LIST_ITEM_SELECTED`() { + val viewModel = createViewModel() + + viewModel.openPage(SITE_EDITOR_PAGE_ID, PageRsListTab.PUBLISHED) + + verify(analyticsTracker, never()).track( + eq(Stat.PAGES_LIST_ITEM_SELECTED), + any(), + any>() + ) + } + @Test fun `registers with the dispatcher on init and unregisters on clear`() { val viewModel = createViewModel() From adf89f9c12e0822d3ccc370c8e08b61c14aaaad0 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 22 Jun 2026 13:47:26 -0400 Subject: [PATCH 4/4] Resolve detekt findings from the Site Editor row changes - Collapse onEditorThemeChanged guards to stay within the return-count limit. - Compute empty actions for the SITE_EDITOR row instead of an early return. - Extract the row's text resolution into a helper to keep PageContentItem under the cyclomatic-complexity threshold. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/pagesrs/PagesRsListViewModel.kt | 35 +++++++----- .../android/ui/pagesrs/screens/PageRsRow.kt | 57 ++++++++++++------- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt index 4cdd2a9c5b24..761710f955f1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt @@ -386,9 +386,10 @@ internal class PagesRsListViewModel @Inject constructor( @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) fun onEditorThemeChanged(event: OnEditorThemeChanged) { val site = this.site ?: return - if (site.id != event.siteId) return - val isBlockBased = event.editorTheme?.themeSupport?.isEditorThemeBlockBased() ?: return - if (isBlockBased == isBlockBasedTheme) return + val isBlockBased = event.editorTheme?.themeSupport?.isEditorThemeBlockBased() + if (site.id != event.siteId || isBlockBased == null || isBlockBased == isBlockBasedTheme) { + return + } isBlockBasedTheme = isBlockBased // Rebuild the published tab from cache so the SITE_EDITOR row appears/disappears. if (collections.containsKey(PageRsListTab.PUBLISHED)) { @@ -1262,10 +1263,6 @@ internal class PagesRsListViewModel @Inject constructor( } private fun PageRsListItem.withMenuActions(site: SiteModel?): PageRsListItem { - // The SITE_EDITOR virtual has no backing page, so it gets no overflow menu. - if (this is PageRsListItem.Virtual && kind == PageRsListItem.Virtual.Kind.SITE_EDITOR) { - return this - } val pageOnFront = site?.pageOnFront ?: 0L val pageForPosts = site?.pageForPosts ?: 0L // WP.com capabilities and showOnFront are synced reliably, so the homepage actions @@ -1278,14 +1275,22 @@ internal class PagesRsListViewModel @Inject constructor( } else { true } - val actions = computePageMenuActions( - status = page.status, - isHomepage = pageOnFront != 0L && page.remotePageId == pageOnFront, - isPostsPage = pageForPosts != 0L && page.remotePageId == pageForPosts, - hasPassword = page.hasPassword, - isBlazeEligibleSite = site != null && blazeFeatureUtils.isSiteBlazeEligible(site), - canManageHomepage = canManageHomepage - ) + // The SITE_EDITOR virtual has no backing page, so it gets no overflow menu. Its synthetic + // page already has empty actions, so this leaves it unchanged below. + val isSiteEditor = this is PageRsListItem.Virtual && + kind == PageRsListItem.Virtual.Kind.SITE_EDITOR + val actions = if (isSiteEditor) { + emptyList() + } else { + computePageMenuActions( + status = page.status, + isHomepage = pageOnFront != 0L && page.remotePageId == pageOnFront, + isPostsPage = pageForPosts != 0L && page.remotePageId == pageForPosts, + hasPassword = page.hasPassword, + isBlazeEligibleSite = site != null && blazeFeatureUtils.isSiteBlazeEligible(site), + canManageHomepage = canManageHomepage + ) + } if (actions == page.actions) return this val updated = page.copy(actions = actions) return when (this) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt index 9caff85cef89..b3fdc094ac45 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt @@ -115,26 +115,11 @@ private fun PageContentItem( Spacer(modifier = Modifier.width(12.dp)) } Column(modifier = Modifier.weight(1f)) { - // The SITE_EDITOR row has no backing page, so its title/subtitle come from string - // resources and it shows no header label, badges, or excerpt. - val isSiteEditor = virtualKind == PageRsListItem.Virtual.Kind.SITE_EDITOR - val titleText = if (isSiteEditor) { - stringResource(R.string.virtual_homepage_title) - } else { - page.title.ifBlank { stringResource(R.string.untitled_in_parentheses) } - } - val subtitleText = if (isSiteEditor) { - stringResource(R.string.virtual_homepage_subtitle) - } else { - page.excerpt - } - val virtualLabel = virtualKind - ?.takeUnless { isSiteEditor } - ?.let { stringResource(it.labelResId()) } + val rowText = pageRowText(page, virtualKind) val statusLabel = page.statusLabelResId.takeIf { it != 0 }?.let { stringResource(it) } val bullet = stringResource(R.string.bullet_with_spaces) val headerText = listOfNotNull( - virtualLabel, + rowText.headerLabel, statusLabel, page.date.takeIf { it.isNotBlank() }, page.authorDisplayName?.takeIf { it.isNotBlank() } @@ -152,15 +137,15 @@ private fun PageContentItem( Spacer(modifier = Modifier.height(4.dp)) } Text( - text = titleText, + text = rowText.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (subtitleText.isNotBlank()) { + if (rowText.subtitle.isNotBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = subtitleText, + text = rowText.subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -297,6 +282,38 @@ private fun ErrorItem(modifier: Modifier = Modifier) { } } +private data class PageRowText( + val title: String, + val subtitle: String, + val headerLabel: String? +) + +/** + * Resolves the row's title, subtitle, and header label. The SITE_EDITOR row has no backing page, so + * its title/subtitle come from string resources and it shows no header label. + */ +@Composable +private fun pageRowText( + page: PageRsUiModel, + virtualKind: PageRsListItem.Virtual.Kind? +): PageRowText { + val isSiteEditor = virtualKind == PageRsListItem.Virtual.Kind.SITE_EDITOR + val title = if (isSiteEditor) { + stringResource(R.string.virtual_homepage_title) + } else { + page.title.ifBlank { stringResource(R.string.untitled_in_parentheses) } + } + val subtitle = if (isSiteEditor) { + stringResource(R.string.virtual_homepage_subtitle) + } else { + page.excerpt + } + val headerLabel = virtualKind + ?.takeUnless { isSiteEditor } + ?.let { stringResource(it.labelResId()) } + return PageRowText(title, subtitle, headerLabel) +} + private fun PageRsListItem.Virtual.Kind.icon(): ImageVector = when (this) { PageRsListItem.Virtual.Kind.HOMEPAGE, PageRsListItem.Virtual.Kind.SITE_EDITOR -> Icons.Filled.Home