fix(mobile): lock Hide Special Badges on a free plan, as desktop does

The paid lock missed this Appearance switch, which desktop also marks paid,
so on a free or trial plan it flipped back off without a reason. It now
shows the paid badge and offers the upgrade, and a test checks that each
settings screen locks the paid settings it changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-13 01:55:30 +08:00
parent 515bc47ae3
commit 51a32f11ec
2 changed files with 49 additions and 3 deletions
@@ -0,0 +1,42 @@
import { readdirSync, readFileSync } from "node:fs"
import { PAID_SETTINGS, SettingPaidLevels } from "@follow/shared/settings/constants"
import { describe, expect, it } from "vitest"
const routesDir = new URL("../routes/", import.meta.url)
// The setter each namespace's settings screens save with, and the hook that says the plan lacks one
const SETTING_HOOKS = {
general: { setter: "setGeneralSetting", lockedHook: "useGeneralPaidSettingLocked" },
ui: { setter: "setUISetting", lockedHook: "useUIPaidSettingLocked" },
} as const
// A call with the key as its first argument, however the formatter wraps it
const callsWithKey = (source: string, fn: string, key: string) =>
new RegExp(String.raw`\b${fn}\(\s*"${key}"\s*[,)]`).test(source)
describe("PaidSettingCell", () => {
it("locks every paid setting a settings screen changes", () => {
const unlocked: string[] = []
for (const file of readdirSync(routesDir).filter((name) => name.endsWith(".tsx"))) {
const source = readFileSync(new URL(file, routesDir), "utf8")
for (const [namespace, { setter, lockedHook }] of Object.entries(SETTING_HOOKS)) {
const paidSettings: Record<string, SettingPaidLevels> =
PAID_SETTINGS[namespace as keyof typeof SETTING_HOOKS]
for (const [key, level] of Object.entries(paidSettings)) {
// Free plans have these, so nothing locks them
if (level === SettingPaidLevels.Free || level === SettingPaidLevels.FreeLimited) continue
// A switch for a paid setting without its lock refuses to turn on without saying why
if (callsWithKey(source, setter, key) && !callsWithKey(source, lockedHook, key)) {
unlocked.push(`${file}: ${key}`)
}
}
}
}
expect(unlocked).toEqual([])
})
})
@@ -2,7 +2,7 @@ import { getUnreadAll } from "@follow/store/unread/getters"
import { useTranslation } from "react-i18next"
import type { AppColorScheme } from "@/src/atoms/settings/ui"
import { setUISetting, useUISettingKey } from "@/src/atoms/settings/ui"
import { setUISetting, useUIPaidSettingLocked, useUISettingKey } from "@/src/atoms/settings/ui"
import {
NavigationBlurEffectHeaderView,
SafeNavigationScrollView,
@@ -22,6 +22,7 @@ import {
setBadgeCountAsyncWithPermission,
} from "@/src/lib/permission"
import { PaidSettingCell } from "../components/PaidSettingCell"
import { ReadingScreen } from "./Reading"
const fontSizePresets = [
@@ -82,6 +83,7 @@ export const AppearanceScreen = () => {
)
const showUnreadCountBadgeMobile = useUISettingKey("showUnreadCountBadgeMobile")
const hideExtraBadge = useUISettingKey("hideExtraBadge")
const isHideExtraBadgeLocked = useUIPaidSettingLocked("hideExtraBadge")
const thumbnailRatio = useUISettingKey("thumbnailRatio")
// Font scaling settings
@@ -164,18 +166,20 @@ export const AppearanceScreen = () => {
<GroupedInsetListSectionHeader label={t("appearance.subscriptions")} marginSize="small" />
<GroupedInsetListCard>
<GroupedInsetListCell
<PaidSettingCell
label={t("appearance.hide_extra_badge.label")}
description={t("appearance.hide_extra_badge.description_mobile")}
locked={isHideExtraBadgeLocked}
>
<Switch
size="sm"
value={hideExtraBadge}
disabled={isHideExtraBadgeLocked}
onValueChange={(val) => {
setUISetting("hideExtraBadge", val)
}}
/>
</GroupedInsetListCell>
</PaidSettingCell>
<GroupedInsetListCell
label={t("appearance.thumbnail_ratio.title")}
description={t("appearance.thumbnail_ratio.description")}