From 51a32f11ec5c60a34b4c7a0b56053ad7e6c625e9 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 13 Oct 2026 01:55:30 +0800 Subject: [PATCH] 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 --- .../components/PaidSettingCell.test.ts | 42 +++++++++++++++++++ .../modules/settings/routes/Appearance.tsx | 10 +++-- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/modules/settings/components/PaidSettingCell.test.ts diff --git a/apps/mobile/src/modules/settings/components/PaidSettingCell.test.ts b/apps/mobile/src/modules/settings/components/PaidSettingCell.test.ts new file mode 100644 index 000000000..5a1ded2c2 --- /dev/null +++ b/apps/mobile/src/modules/settings/components/PaidSettingCell.test.ts @@ -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 = + 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([]) + }) +}) diff --git a/apps/mobile/src/modules/settings/routes/Appearance.tsx b/apps/mobile/src/modules/settings/routes/Appearance.tsx index 29e980ec7..4159befe2 100644 --- a/apps/mobile/src/modules/settings/routes/Appearance.tsx +++ b/apps/mobile/src/modules/settings/routes/Appearance.tsx @@ -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 = () => { - { setUISetting("hideExtraBadge", val) }} /> - +