Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
26.8
-----
* [**] Resolved an issue where the editor could become impossible to exit when it failed to load.
* [*] Try out the next-generation block editor on a per-site basis from Site Settings.

26.7
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import org.wordpress.android.ui.pages.SnackbarMessageHolder
import org.wordpress.android.ui.photopicker.MediaPickerConstants
import org.wordpress.android.ui.photopicker.MediaPickerLauncher
import org.wordpress.android.ui.posts.BasicDialogViewModel
import org.wordpress.android.ui.posts.GutenbergKitAnnouncementBottomSheetFragment
import org.wordpress.android.ui.posts.PostListType
import org.wordpress.android.ui.posts.PostUtils
import org.wordpress.android.ui.reader.ReaderActivityLauncher
Expand Down Expand Up @@ -402,6 +403,15 @@ class MySiteFragment : Fragment(R.layout.my_site_fragment),
)
}

viewModel.onShowGutenbergKitAnnouncement.observeEvent(viewLifecycleOwner) { site ->
if (parentFragmentManager.isStateSaved) return@observeEvent
if (parentFragmentManager.findFragmentByTag(
GutenbergKitAnnouncementBottomSheetFragment.TAG
) != null) return@observeEvent
GutenbergKitAnnouncementBottomSheetFragment.newInstance(site)
.show(parentFragmentManager, GutenbergKitAnnouncementBottomSheetFragment.TAG)
}

viewModel.refresh.observe(viewLifecycleOwner) {
viewModel.refresh()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import org.wordpress.android.ui.pages.SnackbarMessageHolder
import org.wordpress.android.ui.mediapicker.MediaPickerActivity
import org.wordpress.android.ui.posts.BasicDialogViewModel
import org.wordpress.android.ui.posts.GutenbergEditorPreloader
import org.wordpress.android.ui.posts.GutenbergKitAnnouncementController
import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource
import org.wordpress.android.util.BuildConfigWrapper
import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper
Expand Down Expand Up @@ -68,11 +69,14 @@ class MySiteViewModel @Inject constructor(
private val siteCapabilityChecker: SiteCapabilityChecker,
private val gutenbergEditorPreloader: GutenbergEditorPreloader,
private val siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice,
private val gutenbergKitAnnouncementController: GutenbergKitAnnouncementController,
) : ScopedViewModel(mainDispatcher) {
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
private val _onOpenJetpackInstallFullPluginOnboarding = SingleLiveEvent<Event<Unit>>()
private val _onShowJetpackIndividualPluginOverlay = SingleLiveEvent<Event<Unit>>()
private val _onShowGutenbergKitAnnouncement = SingleLiveEvent<Event<SiteModel>>()
val onShowGutenbergKitAnnouncement: LiveData<Event<SiteModel>> = _onShowGutenbergKitAnnouncement

/* Capture and track the site selected event so we can circumvent refreshing sources on resume
as they're already built on site select. */
Expand Down Expand Up @@ -185,6 +189,7 @@ class MySiteViewModel @Inject constructor(
fun onResume() {
isSiteSelected = false
checkAndShowJetpackFullPluginInstallOnboarding()
checkAndShowGutenbergKitAnnouncement()
selectedSiteRepository.updateSiteSettingsIfNecessary()
selectedSiteRepository.getSelectedSite()?.let {
buildDashboardOrSiteItems(it)
Expand All @@ -205,6 +210,14 @@ class MySiteViewModel @Inject constructor(
}
}

private fun checkAndShowGutenbergKitAnnouncement() {
selectedSiteRepository.getSelectedSite()?.let { selectedSite ->
if (gutenbergKitAnnouncementController.shouldShowAnnouncement(selectedSite)) {
_onShowGutenbergKitAnnouncement.postValue(Event(selectedSite))
}
}
}

fun onSiteNameChosen(input: String) {
siteInfoHeaderCardViewModelSlice.onSiteNameChosen(input)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class EditorCapabilityResolver @Inject constructor(
private val siteSettingsProvider: SiteSettingsProvider,
) {
fun resolveThirdPartyBlocks(site: SiteModel): EditorCapabilityState = when {
!gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden
!gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden
!gutenbergKitPluginsFeature.isEnabled() -> EditorCapabilityState.Hidden
!editorSettingsRepository.getSupportsEditorAssetsForSite(site) ->
EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing)
Expand All @@ -45,7 +45,7 @@ class EditorCapabilityResolver @Inject constructor(
}

fun resolveThemeStyles(site: SiteModel): EditorCapabilityState = when {
!gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden
!gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden
!editorSettingsRepository.getSupportsEditorSettingsForSite(site) ->
EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing)
else -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,10 @@ class EditorLauncher @Inject constructor(
* Determines if GutenbergKit editor should be used based on feature flags and post content.
*/
private fun shouldUseGutenbergKitEditor(params: EditorLauncherParams): Boolean {
val featureState = gutenbergKitFeatureChecker.getFeatureState()
val site = params.siteSource.getSite(siteStore)
val featureState = gutenbergKitFeatureChecker.getFeatureState(site)
val isGutenbergFeatureEnabled = featureState.isGutenbergKitEnabled

val site = params.siteSource.getSite(siteStore)
return when {
!isGutenbergFeatureEnabled -> {
logFeatureDisabledReason(featureState)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.wordpress.android.ui.posts

import android.content.DialogInterface
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import dagger.hilt.android.AndroidEntryPoint
import org.wordpress.android.R
import org.wordpress.android.WordPress
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.ui.WPWebViewActivity
import org.wordpress.android.util.extensions.getSerializableCompat
import javax.inject.Inject

/**
* One-time announcement bottom sheet for the upcoming GutenbergKit editor. Show/defer/activate
* logic lives in [GutenbergKitAnnouncementController]; this fragment only renders and forwards
* button taps.
*/
@AndroidEntryPoint
class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() {
@Inject lateinit var controller: GutenbergKitAnnouncementController

private var decisionRecorded = false

private val site: SiteModel
get() = requireNotNull(
requireArguments().getSerializableCompat<SiteModel>(WordPress.SITE)
) { "GutenbergKitAnnouncementBottomSheetFragment requires a SiteModel argument" }

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.gutenberg_kit_announcement_bottom_sheet, container, false)

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// Prevent Material's BottomSheetDialog from applying status-bar insets as top padding.
// Consume only the status-bar inset so IME and gesture insets still propagate to children.
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
v.setPadding(v.paddingLeft, 0, v.paddingRight, v.paddingBottom)
WindowInsetsCompat.Builder(insets)
.setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE)
.build()
}

bindBodyWithLearnMore(view.findViewById(R.id.body_text))

view.findViewById<MaterialButton>(R.id.activate_button).setOnClickListener {
controller.onActivate(site)
decisionRecorded = true
dismiss()
}

view.findViewById<MaterialButton>(R.id.maybe_later_button).setOnClickListener {
controller.onMaybeLater(site)
decisionRecorded = true
dismiss()
}
}

override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
// Swipe / back / tap-outside without a button tap is treated as an implicit "Maybe later"
// so the sheet doesn't re-prompt on the next My Site resume. Config changes don't count.
if (decisionRecorded) return
if (activity?.isChangingConfigurations == true) return
controller.onMaybeLater(site)
}

private fun bindBodyWithLearnMore(textView: TextView) {
val learnMore = getString(R.string.gutenberg_kit_announcement_learn_more)
val combined = SpannableStringBuilder(
getString(R.string.gutenberg_kit_announcement_body, learnMore)
)
val start = combined.indexOf(learnMore)
val end = start + learnMore.length
val color = MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary)
combined.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
WPWebViewActivity.openURL(
requireContext(),
getString(R.string.gutenberg_kit_learn_more_url)
)
}

override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
}, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
combined.setSpan(ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = combined
textView.movementMethod = LinkMovementMethod.getInstance()
}

companion object {
const val TAG = "GutenbergKitAnnouncementBottomSheetFragment"

fun newInstance(site: SiteModel): GutenbergKitAnnouncementBottomSheetFragment =
GutenbergKitAnnouncementBottomSheetFragment().apply {
arguments = Bundle().apply { putSerializable(WordPress.SITE, site) }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.wordpress.android.ui.posts

import org.wordpress.android.datasets.SiteSettingsProvider
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.ui.prefs.AppPrefsWrapper
import java.time.Clock
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
* Owns the decisions for the GutenbergKit announcement bottom sheet and the per-site override
* it writes. Pure logic so it is unit-testable; the fragment and Site Settings only call into it.
*
* The per-site override is the single source of truth — its presence means the user has decided
* for that site (either direction), its absence means "not yet decided." "Maybe later" defers the
* announcement for one week per-site rather than writing an override, so we don't mis-read the
* user's intent.
*/
@Singleton
class GutenbergKitAnnouncementController @Inject constructor(
private val gutenbergKitFeatureChecker: GutenbergKitFeatureChecker,
private val siteSettingsProvider: SiteSettingsProvider,
private val appPrefsWrapper: AppPrefsWrapper,
private val clock: Clock,
) {
@Suppress("ReturnCount")
fun shouldShowAnnouncement(site: SiteModel): Boolean {
// The per-site override/deferral prefs are keyed by URL, so a site without one would
// loop the announcement on every resume (writes would no-op via TextUtils.isEmpty).
if (site.url.isNullOrEmpty()) return false
if (!gutenbergKitFeatureChecker.isGutenbergKitRemoteFeatureEnabled()) return false
if (!siteSettingsProvider.isBlockEditorDefault(site)) return false
if (appPrefsWrapper.getGutenbergKitSiteOverride(site.url) != null) return false
return clock.millis() >= appPrefsWrapper.getGutenbergKitAnnouncementDeferredUntil(site.url)
}

fun onActivate(site: SiteModel) = setOverride(site, true)

/**
* Records an explicit per-site decision (from the announcement sheet or Site Settings). An
* explicit decision supersedes any pending "Maybe later" deferral on the same site, so this
* clears the deferral timestamp as well.
*/
fun setOverride(site: SiteModel, enabled: Boolean) {
appPrefsWrapper.setGutenbergKitSiteOverride(site.url, enabled)
appPrefsWrapper.setGutenbergKitAnnouncementDeferredUntil(site.url, 0L)
}

fun onMaybeLater(site: SiteModel) {
appPrefsWrapper.setGutenbergKitAnnouncementDeferredUntil(
site.url,
clock.millis() + DEFER_DURATION_MILLIS
)
}

companion object {
val DEFER_DURATION_MILLIS: Long = TimeUnit.DAYS.toMillis(7)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.wordpress.android.ui.posts

import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.ui.prefs.AppPrefsWrapper
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature
import org.wordpress.android.util.config.GutenbergKitFeature
Expand All @@ -13,49 +15,58 @@ import javax.inject.Singleton
@Singleton
class GutenbergKitFeatureChecker @Inject constructor(
private val experimentalFeatures: ExperimentalFeatures,
private val gutenbergKitFeature: GutenbergKitFeature
private val gutenbergKitFeature: GutenbergKitFeature,
private val appPrefsWrapper: AppPrefsWrapper
) {
/**
* Data class containing the state of all GutenbergKit-related feature flags.
*/
data class FeatureState(
val isExperimentalBlockEditorEnabled: Boolean,
val isGutenbergKitFeatureEnabled: Boolean,
val isDisableExperimentalBlockEditorEnabled: Boolean
val isDisableExperimentalBlockEditorEnabled: Boolean,
val siteOverride: Boolean? = null
) {
/**
* Determines if GutenbergKit should be enabled based on the feature states.
* Determines if GutenbergKit should be enabled.
*
* Resolution: a per-site override, when present, wins over everything except the
* `DISABLE_EXPERIMENTAL_BLOCK_EDITOR` kill switch. When absent, falls back to the
* experimental flag and the remote feature flag.
*/
val isGutenbergKitEnabled: Boolean
get() = (isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled) &&
!isDisableExperimentalBlockEditorEnabled
get() {
val baseEnabled = isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled
val resolved = siteOverride ?: baseEnabled
return resolved && !isDisableExperimentalBlockEditorEnabled
}
}

/**
* Gets the current state of all GutenbergKit-related feature flags.
*
* @return FeatureState containing all flag states and the computed enabled state
* Gets the current state of all GutenbergKit-related feature flags for the given site (if any).
*/
fun getFeatureState(): FeatureState {
@JvmOverloads
fun getFeatureState(site: SiteModel? = null): FeatureState {
return FeatureState(
isExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR),
isGutenbergKitFeatureEnabled = gutenbergKitFeature.isEnabled(),
isDisableExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(
Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR
)
),
siteOverride = site?.url?.let { appPrefsWrapper.getGutenbergKitSiteOverride(it) }
)
}

/**
* Determines if GutenbergKit is enabled based on feature flags.
*
* The feature is enabled if:
* - Either the experimental block editor is enabled OR the GutenbergKit feature flag is on
* - AND the disable experimental block editor flag is NOT enabled
*
* @return true if GutenbergKit should be enabled, false otherwise
* Determines if GutenbergKit is enabled based on feature flags (and optional per-site opt-in).
*/
fun isGutenbergKitEnabled(): Boolean {
return getFeatureState().isGutenbergKitEnabled
@JvmOverloads
fun isGutenbergKitEnabled(site: SiteModel? = null): Boolean {
return getFeatureState(site).isGutenbergKitEnabled
}

/**
* Whether the user-facing remote feature flag is on (controls opt-in surfaces).
*/
fun isGutenbergKitRemoteFeatureEnabled(): Boolean = gutenbergKitFeature.isEnabled()
}
Loading
Loading