Skip to content

Commit 046f3cd

Browse files
committed
Add GutenbergKit opt-in announcement and per-site override
Introduces a one-time announcement bottom sheet, shown on the next WPMainActivity.onResume when the GutenbergKit remote feature is on, the current site already defaults to the block editor, and the announcement has not been shown before. The dialog's primary CTA sets an app-wide opt-in flag (UndeletablePrefKey, persists across logout); "Maybe later" dismisses without flipping it. Site Settings gains a per-site GutenbergKit toggle, gated on the same remote flag. The toggle is a tri-state override stored as two StringSets (opt-in / opt-out / follow global), letting a user pin a specific site on or off independent of the global opt-in. Resolution order in GutenbergKitFeatureChecker: kill switch beats everything; then siteOverride ?? globalOptIn ?? experimental ?? remote-feature decides. Threaded through EditorCapabilityResolver so Theme Styles and Third-Party Blocks visibility / application stays in agreement with whether the editor actually opens for the site.
1 parent 82411a0 commit 046f3cd

16 files changed

Lines changed: 497 additions & 28 deletions

WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
import org.wordpress.android.ui.photopicker.MediaPickerLauncher;
108108
import org.wordpress.android.ui.posts.EditorConstants;
109109
import org.wordpress.android.ui.posts.EditorLauncher;
110+
import org.wordpress.android.ui.posts.GutenbergKitAnnouncementBottomSheetFragment;
110111
import org.wordpress.android.ui.posts.PostUtils.EntryPoint;
111112
import org.wordpress.android.ui.prefs.AppPrefs;
112113
import org.wordpress.android.ui.prefs.AppSettingsActivity;
@@ -672,6 +673,11 @@ private void initViewModel() {
672673
.show(getSupportFragmentManager(), FeatureAnnouncementDialogFragment.TAG);
673674
});
674675

676+
mViewModel.getOnGutenbergKitAnnouncementRequested().observe(this, unused -> {
677+
new GutenbergKitAnnouncementBottomSheetFragment()
678+
.show(getSupportFragmentManager(), GutenbergKitAnnouncementBottomSheetFragment.TAG);
679+
});
680+
675681
mFloatingActionButton.setOnClickListener(v -> {
676682
PageType selectedPage = getSelectedPage();
677683
if (selectedPage != null) mViewModel.onFabClicked(getSelectedSite(), selectedPage);

WordPress/src/main/java/org/wordpress/android/ui/posts/EditorCapabilityResolver.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class EditorCapabilityResolver @Inject constructor(
3131
private val siteSettingsProvider: SiteSettingsProvider,
3232
) {
3333
fun resolveThirdPartyBlocks(site: SiteModel): EditorCapabilityState = when {
34-
!gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden
34+
!gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden
3535
!gutenbergKitPluginsFeature.isEnabled() -> EditorCapabilityState.Hidden
3636
!editorSettingsRepository.getSupportsEditorAssetsForSite(site) ->
3737
EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing)
@@ -45,7 +45,7 @@ class EditorCapabilityResolver @Inject constructor(
4545
}
4646

4747
fun resolveThemeStyles(site: SiteModel): EditorCapabilityState = when {
48-
!gutenbergKitFeatureChecker.isGutenbergKitEnabled() -> EditorCapabilityState.Hidden
48+
!gutenbergKitFeatureChecker.isGutenbergKitEnabled(site) -> EditorCapabilityState.Hidden
4949
!editorSettingsRepository.getSupportsEditorSettingsForSite(site) ->
5050
EditorCapabilityState.Unsupported(EditorCapabilityState.UnsupportedReason.CapabilityMissing)
5151
else -> {

WordPress/src/main/java/org/wordpress/android/ui/posts/EditorLauncher.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ class EditorLauncher @Inject constructor(
8989
* Determines if GutenbergKit editor should be used based on feature flags and post content.
9090
*/
9191
private fun shouldUseGutenbergKitEditor(params: EditorLauncherParams): Boolean {
92-
val featureState = gutenbergKitFeatureChecker.getFeatureState()
92+
val site = params.siteSource.getSite(siteStore)
93+
val featureState = gutenbergKitFeatureChecker.getFeatureState(site)
9394
val isGutenbergFeatureEnabled = featureState.isGutenbergKitEnabled
9495

95-
val site = params.siteSource.getSite(siteStore)
9696
return when {
9797
!isGutenbergFeatureEnabled -> {
9898
logFeatureDisabledReason(featureState)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package org.wordpress.android.ui.posts
2+
3+
import android.os.Bundle
4+
import android.text.SpannableStringBuilder
5+
import android.text.Spanned
6+
import android.text.TextPaint
7+
import android.text.method.LinkMovementMethod
8+
import android.text.style.ClickableSpan
9+
import android.text.style.ForegroundColorSpan
10+
import android.view.LayoutInflater
11+
import android.view.View
12+
import android.view.ViewGroup
13+
import android.widget.TextView
14+
import androidx.core.view.ViewCompat
15+
import androidx.core.view.WindowInsetsCompat
16+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
17+
import com.google.android.material.button.MaterialButton
18+
import com.google.android.material.color.MaterialColors
19+
import org.wordpress.android.R
20+
import org.wordpress.android.ui.WPWebViewActivity
21+
import org.wordpress.android.ui.prefs.AppPrefs
22+
23+
/**
24+
* One-time announcement bottom sheet for the upcoming GutenbergKit editor.
25+
* Sets the app-wide opt-in flag, or dismisses. Provides a "Learn more" link
26+
* that opens a web page.
27+
*/
28+
class GutenbergKitAnnouncementBottomSheetFragment : BottomSheetDialogFragment() {
29+
override fun onCreateView(
30+
inflater: LayoutInflater,
31+
container: ViewGroup?,
32+
savedInstanceState: Bundle?
33+
): View = inflater.inflate(R.layout.gutenberg_kit_announcement_bottom_sheet, container, false)
34+
35+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
36+
super.onViewCreated(view, savedInstanceState)
37+
38+
// Prevent Material's BottomSheetDialog from applying status-bar insets as top padding.
39+
ViewCompat.setOnApplyWindowInsetsListener(view) { v, _ ->
40+
v.setPadding(v.paddingLeft, 0, v.paddingRight, v.paddingBottom)
41+
WindowInsetsCompat.CONSUMED
42+
}
43+
44+
bindBodyWithLearnMore(view.findViewById(R.id.body_text))
45+
46+
view.findViewById<MaterialButton>(R.id.try_now_button).setOnClickListener {
47+
AppPrefs.setGutenbergKitUserOptedIn(true)
48+
dismiss()
49+
}
50+
51+
view.findViewById<MaterialButton>(R.id.maybe_later_button).setOnClickListener {
52+
dismiss()
53+
}
54+
}
55+
56+
private fun bindBodyWithLearnMore(textView: TextView) {
57+
val body = getString(R.string.gutenberg_kit_announcement_body)
58+
val learnMore = getString(R.string.gutenberg_kit_announcement_learn_more)
59+
val combined = SpannableStringBuilder(body).append(' ').append(learnMore)
60+
val start = combined.length - learnMore.length
61+
val end = combined.length
62+
val color = MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary)
63+
combined.setSpan(object : ClickableSpan() {
64+
override fun onClick(widget: View) {
65+
WPWebViewActivity.openURL(
66+
requireContext(),
67+
getString(R.string.gutenberg_kit_learn_more_url)
68+
)
69+
}
70+
71+
override fun updateDrawState(ds: TextPaint) {
72+
super.updateDrawState(ds)
73+
ds.isUnderlineText = false
74+
}
75+
}, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
76+
combined.setSpan(ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
77+
textView.text = combined
78+
textView.movementMethod = LinkMovementMethod.getInstance()
79+
}
80+
81+
companion object {
82+
const val TAG = "GutenbergKitAnnouncementBottomSheetFragment"
83+
}
84+
}
Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.wordpress.android.ui.posts
22

3+
import org.wordpress.android.fluxc.model.SiteModel
4+
import org.wordpress.android.ui.prefs.AppPrefsWrapper
35
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures
46
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature
57
import org.wordpress.android.util.config.GutenbergKitFeature
@@ -13,49 +15,58 @@ import javax.inject.Singleton
1315
@Singleton
1416
class GutenbergKitFeatureChecker @Inject constructor(
1517
private val experimentalFeatures: ExperimentalFeatures,
16-
private val gutenbergKitFeature: GutenbergKitFeature
18+
private val gutenbergKitFeature: GutenbergKitFeature,
19+
private val appPrefsWrapper: AppPrefsWrapper
1720
) {
1821
/**
1922
* Data class containing the state of all GutenbergKit-related feature flags.
2023
*/
2124
data class FeatureState(
2225
val isExperimentalBlockEditorEnabled: Boolean,
2326
val isGutenbergKitFeatureEnabled: Boolean,
24-
val isDisableExperimentalBlockEditorEnabled: Boolean
27+
val isDisableExperimentalBlockEditorEnabled: Boolean,
28+
val isUserOptedIn: Boolean = false,
29+
val siteOverride: Boolean? = null
2530
) {
2631
/**
2732
* Determines if GutenbergKit should be enabled based on the feature states.
2833
*/
2934
val isGutenbergKitEnabled: Boolean
30-
get() = (isExperimentalBlockEditorEnabled || isGutenbergKitFeatureEnabled) &&
31-
!isDisableExperimentalBlockEditorEnabled
35+
get() {
36+
val perUserOrSite = siteOverride ?: isUserOptedIn
37+
return (isExperimentalBlockEditorEnabled ||
38+
isGutenbergKitFeatureEnabled ||
39+
perUserOrSite) &&
40+
!isDisableExperimentalBlockEditorEnabled
41+
}
3242
}
3343

3444
/**
35-
* Gets the current state of all GutenbergKit-related feature flags.
36-
*
37-
* @return FeatureState containing all flag states and the computed enabled state
45+
* Gets the current state of all GutenbergKit-related feature flags for the given site (if any).
3846
*/
39-
fun getFeatureState(): FeatureState {
47+
@JvmOverloads
48+
fun getFeatureState(site: SiteModel? = null): FeatureState {
4049
return FeatureState(
4150
isExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR),
4251
isGutenbergKitFeatureEnabled = gutenbergKitFeature.isEnabled(),
4352
isDisableExperimentalBlockEditorEnabled = experimentalFeatures.isEnabled(
4453
Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR
45-
)
54+
),
55+
isUserOptedIn = appPrefsWrapper.isGutenbergKitUserOptedIn,
56+
siteOverride = site?.url?.let { appPrefsWrapper.getGutenbergKitSiteOverride(it) }
4657
)
4758
}
4859

4960
/**
50-
* Determines if GutenbergKit is enabled based on feature flags.
51-
*
52-
* The feature is enabled if:
53-
* - Either the experimental block editor is enabled OR the GutenbergKit feature flag is on
54-
* - AND the disable experimental block editor flag is NOT enabled
55-
*
56-
* @return true if GutenbergKit should be enabled, false otherwise
61+
* Determines if GutenbergKit is enabled based on feature flags (and optional per-site opt-in).
5762
*/
58-
fun isGutenbergKitEnabled(): Boolean {
59-
return getFeatureState().isGutenbergKitEnabled
63+
@JvmOverloads
64+
fun isGutenbergKitEnabled(site: SiteModel? = null): Boolean {
65+
return getFeatureState(site).isGutenbergKitEnabled
6066
}
67+
68+
/**
69+
* Whether the user-facing remote feature flag is on (controls opt-in surfaces).
70+
*/
71+
fun isGutenbergKitRemoteFeatureEnabled(): Boolean = gutenbergKitFeature.isEnabled()
6172
}

WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ public enum DeletablePrefKey implements PrefKey {
127127
SHOULD_AUTO_ENABLE_GUTENBERG_FOR_THE_NEW_POSTS_PHASE_2,
128128
GUTENBERG_OPT_IN_DIALOG_SHOWN,
129129
GUTENBERG_FOCAL_POINT_PICKER_TOOLTIP_SHOWN,
130+
GUTENBERG_KIT_OPT_IN_SITES,
131+
GUTENBERG_KIT_OPT_OUT_SITES,
130132

131133
POST_LIST_AUTHOR_FILTER,
132134
POST_LIST_VIEW_LAYOUT_TYPE,
@@ -322,6 +324,9 @@ public enum UndeletablePrefKey implements PrefKey {
322324
// These preferences persist across logout/login cycles.
323325
IS_TRACK_NETWORK_REQUESTS_ENABLED,
324326
TRACK_NETWORK_REQUESTS_RETENTION_PERIOD,
327+
328+
GUTENBERG_KIT_ANNOUNCEMENT_SHOWN,
329+
GUTENBERG_KIT_USER_OPT_IN,
325330
}
326331

327332
static SharedPreferences prefs() {
@@ -831,6 +836,120 @@ public static boolean isGutenbergInfoPopupDisplayed(String siteURL) {
831836
return urls != null && urls.contains(siteURL);
832837
}
833838

839+
/**
840+
* Returns the explicit per-site override for GutenbergKit, or {@code null} if the user has
841+
* not set one (in which case the global opt-in flag should be used).
842+
*/
843+
@Nullable
844+
public static Boolean getGutenbergKitSiteOverride(String siteURL) {
845+
if (TextUtils.isEmpty(siteURL)) {
846+
return null;
847+
}
848+
if (siteSetContains(DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES, siteURL)) {
849+
return Boolean.FALSE;
850+
}
851+
if (siteSetContains(DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES, siteURL)) {
852+
return Boolean.TRUE;
853+
}
854+
return null;
855+
}
856+
857+
/**
858+
* Sets an explicit per-site override for GutenbergKit, replacing any prior override for the
859+
* site. The site is added to the opt-in or opt-out set (per {@code enabled}) and removed from
860+
* the other so the two sets stay mutually exclusive.
861+
*/
862+
public static void setGutenbergKitSiteOverride(String siteURL, boolean enabled) {
863+
if (TextUtils.isEmpty(siteURL)) {
864+
return;
865+
}
866+
DeletablePrefKey added = enabled
867+
? DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES
868+
: DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES;
869+
DeletablePrefKey removed = enabled
870+
? DeletablePrefKey.GUTENBERG_KIT_OPT_OUT_SITES
871+
: DeletablePrefKey.GUTENBERG_KIT_OPT_IN_SITES;
872+
addToSiteSet(added, siteURL);
873+
removeFromSiteSet(removed, siteURL);
874+
}
875+
876+
/**
877+
* Returns {@code true} if {@code siteURL} is currently a member of the StringSet at {@code key}.
878+
* A missing entry or a value of the wrong type is treated as absence.
879+
*/
880+
private static boolean siteSetContains(DeletablePrefKey key, String siteURL) {
881+
try {
882+
Set<String> urls = prefs().getStringSet(key.name(), null);
883+
return urls != null && urls.contains(siteURL);
884+
} catch (ClassCastException exp) {
885+
return false;
886+
}
887+
}
888+
889+
/**
890+
* Adds {@code siteURL} to the StringSet at {@code key}, creating the set if it does not exist.
891+
* No-ops if the stored value is of the wrong type.
892+
*/
893+
private static void addToSiteSet(DeletablePrefKey key, String siteURL) {
894+
Set<String> urls;
895+
try {
896+
urls = prefs().getStringSet(key.name(), null);
897+
} catch (ClassCastException exp) {
898+
return;
899+
}
900+
Set<String> newUrls = new HashSet<>();
901+
if (urls != null) newUrls.addAll(urls);
902+
newUrls.add(siteURL);
903+
prefs().edit().putStringSet(key.name(), newUrls).apply();
904+
}
905+
906+
/**
907+
* Removes {@code siteURL} from the StringSet at {@code key}. No-ops if the site is not present
908+
* or the stored value is of the wrong type.
909+
*/
910+
private static void removeFromSiteSet(DeletablePrefKey key, String siteURL) {
911+
Set<String> urls;
912+
try {
913+
urls = prefs().getStringSet(key.name(), null);
914+
} catch (ClassCastException exp) {
915+
return;
916+
}
917+
if (urls == null || !urls.contains(siteURL)) return;
918+
Set<String> newUrls = new HashSet<>(urls);
919+
newUrls.remove(siteURL);
920+
prefs().edit().putStringSet(key.name(), newUrls).apply();
921+
}
922+
923+
/**
924+
* Returns {@code true} if the user has opted into GutenbergKit app-wide via the announcement
925+
* sheet. The opt-in is the baseline that per-site overrides modify.
926+
*/
927+
public static boolean isGutenbergKitUserOptedIn() {
928+
return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), false);
929+
}
930+
931+
/**
932+
* Sets the app-wide GutenbergKit opt-in. Persists across logout (undeletable pref).
933+
*/
934+
public static void setGutenbergKitUserOptedIn(boolean optedIn) {
935+
prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_USER_OPT_IN.name(), optedIn).apply();
936+
}
937+
938+
/**
939+
* Returns {@code true} if the GutenbergKit announcement bottom sheet has been presented to
940+
* this user. Used to ensure the announcement is shown at most once.
941+
*/
942+
public static boolean wasGutenbergKitAnnouncementShown() {
943+
return prefs().getBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), false);
944+
}
945+
946+
/**
947+
* Records whether the GutenbergKit announcement bottom sheet has been shown.
948+
*/
949+
public static void setGutenbergKitAnnouncementShown(boolean shown) {
950+
prefs().edit().putBoolean(UndeletablePrefKey.GUTENBERG_KIT_ANNOUNCEMENT_SHOWN.name(), shown).apply();
951+
}
952+
834953
public static void setGutenbergInfoPopupDisplayed(String siteURL, boolean isDisplayed) {
835954
if (isGutenbergInfoPopupDisplayed(siteURL)) {
836955
return;

WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,20 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra
575575
fun setStatsNewStatsSuggestionLastDismissedAt(timestamp: Long) =
576576
AppPrefs.setStatsNewStatsSuggestionLastDismissedAt(timestamp)
577577

578+
fun getGutenbergKitSiteOverride(siteUrl: String?): Boolean? =
579+
AppPrefs.getGutenbergKitSiteOverride(siteUrl)
580+
581+
fun setGutenbergKitSiteOverride(siteUrl: String?, enabled: Boolean) =
582+
AppPrefs.setGutenbergKitSiteOverride(siteUrl, enabled)
583+
584+
var isGutenbergKitUserOptedIn: Boolean
585+
get() = AppPrefs.isGutenbergKitUserOptedIn()
586+
set(value) = AppPrefs.setGutenbergKitUserOptedIn(value)
587+
588+
var wasGutenbergKitAnnouncementShown: Boolean
589+
get() = AppPrefs.wasGutenbergKitAnnouncementShown()
590+
set(value) = AppPrefs.setGutenbergKitAnnouncementShown(value)
591+
578592
companion object {
579593
private const val LIGHT_MODE_ID = 0
580594
private const val DARK_MODE_ID = 1

0 commit comments

Comments
 (0)