From 1dc39c4cdfcd450c175ba1df838377e24f09d473 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 14 Aug 2026 13:01:41 +0800 Subject: [PATCH 1/3] Add app icon badge permission controls --- src/App.tsx | 4 +- src/__tests__/unit/App.firebase-auth.test.tsx | 4 + src/__tests__/unit/App.integration.test.tsx | 4 + .../unit/components/AppBadgeManager.test.tsx | 83 +++++++++++ src/__tests__/unit/hooks/useAppBadge.test.ts | 138 +++++++++++++++++- .../unit/lib/preferencesStore.test.ts | 28 ++++ .../unit/routes/settings.advanced.test.tsx | 33 +++++ src/components/AppBadgeManager.tsx | 65 +++++++++ src/hooks/useAppBadge.ts | 103 +++++++++++-- src/lib/preferencesStore.ts | 5 + src/routes/settings.advanced.tsx | 19 +++ src/translations/de-DE.json | 10 ++ src/translations/en-GB.json | 10 ++ src/translations/en-US.json | 10 ++ src/translations/es-ES.json | 10 ++ src/translations/fr-FR.json | 12 +- src/translations/ja-JP.json | 10 ++ src/translations/ko-KR.json | 10 ++ src/translations/nl-NL.json | 10 ++ src/translations/zh-CN.json | 12 +- 20 files changed, 560 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/unit/components/AppBadgeManager.test.tsx create mode 100644 src/components/AppBadgeManager.tsx diff --git a/src/App.tsx b/src/App.tsx index 6fd015b..8831cfc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,11 @@ import { usePrevious } from "@uidotdev/usehooks"; import { useTranslation } from "react-i18next"; import { FirebaseAuthentication } from "@capacitor-firebase/authentication"; import { ErrorComponent } from "@/components/ErrorComponent.tsx"; +import { AppBadgeManager } from "@/components/AppBadgeManager"; import { InboxModal } from "@/components/InboxModal"; import { PAGE_SCROLL_RESTORATION_SELECTOR } from "@/components/PageFrame"; import { StagedTokenModal } from "@/components/home/StagedTokenModal"; import { useAppReviewPrompt } from "@/hooks/useAppReviewPrompt"; -import { useAppBadge } from "@/hooks/useAppBadge"; import { isNativePluginAvailable, isPluginAvailable, @@ -263,7 +263,6 @@ export default function App() { useNfcAvailabilityCheck(); useCameraAvailabilityCheck(); useAccelerometerAvailabilityCheck(); - useAppBadge(); useAppReviewPrompt(); // Initialize live updates - must be called after app renders successfully useLiveUpdate(); @@ -468,6 +467,7 @@ export default function App() { + {/* Must live inside A11yAnnouncerProvider and SlideModalProvider: its WriteModal uses useAnnouncer and its confirm modal renders a SlideModal */} diff --git a/src/__tests__/unit/App.firebase-auth.test.tsx b/src/__tests__/unit/App.firebase-auth.test.tsx index 50eb435..2393483 100644 --- a/src/__tests__/unit/App.firebase-auth.test.tsx +++ b/src/__tests__/unit/App.firebase-auth.test.tsx @@ -246,6 +246,10 @@ vi.mock("@/components/InboxModal", () => ({ InboxModal: () =>
, })); +vi.mock("@/components/AppBadgeManager", () => ({ + AppBadgeManager: () =>
, +})); + vi.mock("@/components/A11yAnnouncer", () => ({ A11yAnnouncerProvider: ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/src/__tests__/unit/App.integration.test.tsx b/src/__tests__/unit/App.integration.test.tsx index fb263a4..9d57b9c 100644 --- a/src/__tests__/unit/App.integration.test.tsx +++ b/src/__tests__/unit/App.integration.test.tsx @@ -204,6 +204,10 @@ vi.mock("@/components/InboxModal", () => ({ InboxModal: () =>
, })); +vi.mock("@/components/AppBadgeManager", () => ({ + AppBadgeManager: () =>
, +})); + vi.mock("@/components/home/StagedTokenModal", () => ({ StagedTokenModal: () =>
, })); diff --git a/src/__tests__/unit/components/AppBadgeManager.test.tsx b/src/__tests__/unit/components/AppBadgeManager.test.tsx new file mode 100644 index 0000000..2d9197d --- /dev/null +++ b/src/__tests__/unit/components/AppBadgeManager.test.tsx @@ -0,0 +1,83 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppBadgeManager } from "@/components/AppBadgeManager"; +import { SlideModalProvider } from "@/components/SlideModalProvider"; +import { useAppBadge } from "@/hooks/useAppBadge"; +import { render, screen } from "@/test-utils"; + +const declinePermissionRationale = vi.fn(); +const dismissPermissionDeniedHelp = vi.fn(); +const requestPermission = vi.fn(); + +vi.mock("@/hooks/useAppBadge", () => ({ + useAppBadge: vi.fn(), +})); + +vi.mock("@/hooks/useHaptics", () => ({ + useHaptics: () => ({ impact: vi.fn() }), +})); + +function renderManager() { + return render( + + + , + ); +} + +describe("AppBadgeManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAppBadge).mockReturnValue({ + showPermissionRationale: true, + showPermissionDeniedHelp: false, + isRequestingPermission: false, + declinePermissionRationale, + dismissPermissionDeniedHelp, + requestPermission, + }); + }); + + it("should explain badge-only permission before continuing", () => { + renderManager(); + + expect( + screen.getByRole("dialog", { name: "appBadge.title" }), + ).toBeInTheDocument(); + expect(screen.getByText("appBadge.description")).toBeInTheDocument(); + }); + + it("should decline the rationale without requesting permission", async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(screen.getByRole("button", { name: "appBadge.noThanks" })); + + expect(declinePermissionRationale).toHaveBeenCalledOnce(); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it("should request permission only after the user continues", async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(screen.getByRole("button", { name: "appBadge.continue" })); + + expect(requestPermission).toHaveBeenCalledOnce(); + }); + + it("should explain how to recover denied system permission", () => { + vi.mocked(useAppBadge).mockReturnValue({ + showPermissionRationale: false, + showPermissionDeniedHelp: true, + isRequestingPermission: false, + declinePermissionRationale, + dismissPermissionDeniedHelp, + requestPermission, + }); + + renderManager(); + + expect(screen.getByText("appBadge.deniedDescription")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/unit/hooks/useAppBadge.test.ts b/src/__tests__/unit/hooks/useAppBadge.test.ts index 20b3153..816563b 100644 --- a/src/__tests__/unit/hooks/useAppBadge.test.ts +++ b/src/__tests__/unit/hooks/useAppBadge.test.ts @@ -5,6 +5,7 @@ import { useAppBadge } from "@/hooks/useAppBadge"; import { CoreAPI } from "@/lib/coreApi"; import { isNativePluginAvailable } from "@/lib/capacitorBridge"; import { InboxSeverity } from "@/lib/models"; +import { usePreferencesStore } from "@/lib/preferencesStore"; import { useStatusStore } from "@/lib/store"; import { mockInboxMessage } from "@/test-utils/factories"; @@ -19,6 +20,8 @@ vi.mock("@capawesome/capacitor-badge", () => ({ vi.mock("@/lib/capacitorBridge", () => ({ isNativePluginAvailable: vi.fn(), + isPluginAvailable: vi.fn(() => true), + isCapacitorPluginUnavailableError: vi.fn(() => false), })); describe("useAppBadge", () => { @@ -26,6 +29,7 @@ describe("useAppBadge", () => { CoreAPI.reset(); vi.clearAllMocks(); useStatusStore.setState({ inboxMessages: [] }); + usePreferencesStore.setState({ appBadgeEnabled: true }); vi.mocked(isNativePluginAvailable).mockReturnValue(true); vi.mocked(Badge.isSupported).mockResolvedValue({ isSupported: true }); vi.mocked(Badge.checkPermissions).mockResolvedValue({ @@ -60,30 +64,154 @@ describe("useAppBadge", () => { }); }); - it("should request permission when the first notification arrives", async () => { + it("should show a rationale before requesting permission", async () => { vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); useStatusStore.setState({ inboxMessages: [mockInboxMessage({ id: 1 })], }); + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + expect(Badge.requestPermissions).not.toHaveBeenCalled(); + expect(Badge.set).not.toHaveBeenCalled(); + }); + + it("should set the current badge count after permission is granted", async () => { + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + + act(() => { + useStatusStore.setState({ + inboxMessages: [ + mockInboxMessage({ id: 1 }), + mockInboxMessage({ id: 2 }), + ], + }); + }); + + await act(async () => { + await result.current.requestPermission(); + }); + + expect(Badge.requestPermissions).toHaveBeenCalledOnce(); + expect(Badge.set).toHaveBeenCalledWith({ count: 2 }); + expect(result.current.showPermissionRationale).toBe(false); + }); + + it("should disable badges when the native prompt is denied", async () => { + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); + vi.mocked(Badge.requestPermissions).mockResolvedValue({ + display: "denied", + }); + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + + await act(async () => { + await result.current.requestPermission(); + }); + + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); + expect(result.current.showPermissionDeniedHelp).toBe(true); + expect(Badge.set).not.toHaveBeenCalled(); + }); + + it("should persist opt-out when the rationale is declined", async () => { + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + + act(() => { + result.current.declinePermissionRationale(); + }); + + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); + expect(result.current.showPermissionRationale).toBe(false); + expect(Badge.requestPermissions).not.toHaveBeenCalled(); + }); + + it("should show the rationale when badges are re-enabled in settings", async () => { + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); + usePreferencesStore.setState({ appBadgeEnabled: false }); + + const { result } = renderHook(() => useAppBadge()); + + expect(result.current.showPermissionRationale).toBe(false); + + act(() => { + usePreferencesStore.getState().setAppBadgeEnabled(true); + }); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + }); + + it("should clear an existing badge when disabled", async () => { + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + usePreferencesStore.setState({ appBadgeEnabled: false }); + renderHook(() => useAppBadge()); await waitFor(() => { - expect(Badge.requestPermissions).toHaveBeenCalledOnce(); - expect(Badge.set).toHaveBeenCalledWith({ count: 1 }); + expect(Badge.set).toHaveBeenCalledWith({ count: 0 }); + }); + }); + + it("should explain how to recover when system permission is denied", async () => { + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "denied" }); + usePreferencesStore.setState({ appBadgeEnabled: false }); + + const { result } = renderHook(() => useAppBadge()); + + act(() => { + usePreferencesStore.getState().setAppBadgeEnabled(true); + }); + + await waitFor(() => { + expect(result.current.showPermissionDeniedHelp).toBe(true); }); + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); }); - it("should not set the badge when permission is denied", async () => { + it("should disable badges when permission is denied", async () => { vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "denied" }); renderHook(() => useAppBadge()); await waitFor(() => { - expect(Badge.checkPermissions).toHaveBeenCalledOnce(); + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); }); + expect(Badge.checkPermissions).toHaveBeenCalled(); expect(Badge.requestPermissions).not.toHaveBeenCalled(); expect(Badge.set).not.toHaveBeenCalled(); + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); }); it("should serialize badge writes when the count changes during sync", async () => { diff --git a/src/__tests__/unit/lib/preferencesStore.test.ts b/src/__tests__/unit/lib/preferencesStore.test.ts index 44eb781..9e3a84b 100644 --- a/src/__tests__/unit/lib/preferencesStore.test.ts +++ b/src/__tests__/unit/lib/preferencesStore.test.ts @@ -46,6 +46,7 @@ describe("usePreferencesStore", () => { shakeMode: "random", shakeZapscript: "", systemNameRegion: "auto", + appBadgeEnabled: true, accessibleLists: false, appReviewCadence: { ...DEFAULT_APP_REVIEW_CADENCE }, _hasHydrated: true, // Pretend it's hydrated for tests @@ -131,6 +132,33 @@ describe("usePreferencesStore", () => { }); }); + describe("app badge persistence", () => { + it("should persist the app badge opt-out", async () => { + usePreferencesStore.getState().setAppBadgeEnabled(false); + + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(false); + + await waitFor(() => { + expect(Preferences.set).toHaveBeenCalled(); + }); + const persisted = vi.mocked(Preferences.set).mock.calls.at(-1)?.[0]; + expect(persisted?.value).toContain('"appBadgeEnabled":false'); + }); + + it("should default to enabled for preferences saved before badge control", async () => { + vi.mocked(Preferences.get).mockResolvedValueOnce({ + value: JSON.stringify({ + state: { showFilenames: true }, + version: 0, + }), + }); + + await usePreferencesStore.persist.rehydrate(); + + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(true); + }); + }); + describe("accessible list persistence", () => { it("should update the manual screen-reader list preference", () => { usePreferencesStore.getState().setAccessibleLists(true); diff --git a/src/__tests__/unit/routes/settings.advanced.test.tsx b/src/__tests__/unit/routes/settings.advanced.test.tsx index 7794c6f..1154b24 100644 --- a/src/__tests__/unit/routes/settings.advanced.test.tsx +++ b/src/__tests__/unit/routes/settings.advanced.test.tsx @@ -19,6 +19,9 @@ vi.mock("@/lib/coreApi", () => ({ // Mock stores const mockUseStatusStore = vi.fn(); const mockUsePreferencesStore = vi.fn(); +const { mockIsNativePluginAvailable } = vi.hoisted(() => ({ + mockIsNativePluginAvailable: vi.fn(), +})); vi.mock("@/lib/store", async (importOriginal) => { const actual = (await importOriginal()) as any; @@ -32,6 +35,10 @@ vi.mock("@/lib/preferencesStore", () => ({ usePreferencesStore: (selector: any) => mockUsePreferencesStore(selector), })); +vi.mock("@/lib/capacitorBridge", () => ({ + isNativePluginAvailable: mockIsNativePluginAvailable, +})); + // Mock router - use vi.hoisted to make variables accessible in mocks const { mockNavigate, componentRef } = vi.hoisted(() => ({ mockNavigate: vi.fn(), @@ -94,6 +101,8 @@ describe("Settings Advanced Route", () => { const defaultPreferencesState = { showFilenames: false, setShowFilenames: vi.fn(), + appBadgeEnabled: true, + setAppBadgeEnabled: vi.fn(), }; beforeEach(() => { @@ -113,6 +122,7 @@ describe("Settings Advanced Route", () => { readersAutoDetect: false, }); mockSettingsUpdate.mockResolvedValue({}); + mockIsNativePluginAvailable.mockReturnValue(false); mockUseStatusStore.mockImplementation((selector) => selector(defaultStoreState), @@ -278,6 +288,29 @@ describe("Settings Advanced Route", () => { expect(showFilenames).toBeEnabled(); }); + it("should update app icon badge preference on native platforms", async () => { + const user = userEvent.setup(); + const setAppBadgeEnabled = vi.fn(); + mockIsNativePluginAvailable.mockImplementation( + (pluginName: string) => pluginName === "Badge", + ); + mockUsePreferencesStore.mockImplementation((selector) => + selector({ + ...defaultPreferencesState, + setAppBadgeEnabled, + }), + ); + + renderComponent(); + + const appBadgeToggle = await screen.findByRole("checkbox", { + name: /settings.advanced.appIconBadges/i, + }); + await user.click(appBadgeToggle); + + expect(setAppBadgeEnabled).toHaveBeenCalledWith(false); + }); + it("should call setShowFilenames when show filenames is toggled", async () => { const mockSetShowFilenames = vi.fn(); mockUsePreferencesStore.mockImplementation((selector) => diff --git a/src/components/AppBadgeManager.tsx b/src/components/AppBadgeManager.tsx new file mode 100644 index 0000000..f60a42a --- /dev/null +++ b/src/components/AppBadgeManager.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from "react-i18next"; +import { SlideModal } from "@/components/SlideModal"; +import { Button } from "@/components/wui/Button"; +import { useAppBadge } from "@/hooks/useAppBadge"; + +export function AppBadgeManager() { + const { t } = useTranslation(); + const { + showPermissionRationale, + showPermissionDeniedHelp, + isRequestingPermission, + declinePermissionRationale, + dismissPermissionDeniedHelp, + requestPermission, + } = useAppBadge(); + + return ( + <> + +
+

{t("appBadge.description")}

+
+
+
+
+ + +
+

{t("appBadge.deniedDescription")}

+
+
+ + ); +} diff --git a/src/hooks/useAppBadge.ts b/src/hooks/useAppBadge.ts index ae73848..8051835 100644 --- a/src/hooks/useAppBadge.ts +++ b/src/hooks/useAppBadge.ts @@ -1,14 +1,34 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Badge } from "@capawesome/capacitor-badge"; +import { usePreferencesStore } from "@/lib/preferencesStore"; import { useStatusStore } from "@/lib/store"; import { logger } from "@/lib/logger"; import { isNativePluginAvailable } from "@/lib/capacitorBridge"; +const isPromptPermission = (display: string) => + display === "prompt" || display === "prompt-with-rationale"; + export function useAppBadge() { const inboxCount = useStatusStore((state) => state.inboxMessages.length); + const appBadgeEnabled = usePreferencesStore((state) => state.appBadgeEnabled); + const setAppBadgeEnabled = usePreferencesStore( + (state) => state.setAppBadgeEnabled, + ); const syncQueueRef = useRef>(Promise.resolve()); + const previousEnabledRef = useRef(appBadgeEnabled); + const rationaleHandledRef = useRef(false); + const [showPermissionRationale, setShowPermissionRationale] = useState(false); + const [showPermissionDeniedHelp, setShowPermissionDeniedHelp] = + useState(false); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); useEffect(() => { + const enabledByUser = appBadgeEnabled && !previousEnabledRef.current; + previousEnabledRef.current = appBadgeEnabled; + if (enabledByUser) { + rationaleHandledRef.current = false; + } + if (!isNativePluginAvailable("Badge")) return; let cancelled = false; @@ -20,21 +40,36 @@ export function useAppBadge() { const support = await Badge.isSupported(); if (!support.isSupported || cancelled) return; - let permission = await Badge.checkPermissions(); + const permission = await Badge.checkPermissions(); if (cancelled) return; + if (!appBadgeEnabled) { + if (permission.display === "granted") { + await Badge.set({ count: 0 }); + } + return; + } + + if (permission.display === "granted") { + await Badge.set({ count: inboxCount }); + return; + } + if ( - inboxCount > 0 && - (permission.display === "prompt" || - permission.display === "prompt-with-rationale") + isPromptPermission(permission.display) && + (inboxCount > 0 || enabledByUser) && + !rationaleHandledRef.current ) { - permission = await Badge.requestPermissions(); - if (cancelled) return; + setShowPermissionRationale(true); + return; } - if (permission.display !== "granted") return; - - await Badge.set({ count: inboxCount }); + if (permission.display === "denied") { + setAppBadgeEnabled(false); + if (enabledByUser) { + setShowPermissionDeniedHelp(true); + } + } } catch (error) { if (cancelled) return; @@ -53,5 +88,51 @@ export function useAppBadge() { return () => { cancelled = true; }; - }, [inboxCount]); + }, [appBadgeEnabled, inboxCount, setAppBadgeEnabled]); + + const declinePermissionRationale = useCallback(() => { + rationaleHandledRef.current = true; + setAppBadgeEnabled(false); + setShowPermissionRationale(false); + }, [setAppBadgeEnabled]); + + const dismissPermissionDeniedHelp = useCallback(() => { + setShowPermissionDeniedHelp(false); + }, []); + + const requestPermission = useCallback(async () => { + if (!isNativePluginAvailable("Badge")) return; + + rationaleHandledRef.current = true; + setIsRequestingPermission(true); + try { + const permission = await Badge.requestPermissions(); + if (permission.display === "granted") { + setAppBadgeEnabled(true); + const currentCount = useStatusStore.getState().inboxMessages.length; + await Badge.set({ count: currentCount }); + } else { + setAppBadgeEnabled(false); + setShowPermissionDeniedHelp(true); + } + setShowPermissionRationale(false); + } catch (error) { + logger.error("Failed to request app icon badge permission", error, { + category: "general", + action: "appBadge.requestPermission", + severity: "warning", + }); + } finally { + setIsRequestingPermission(false); + } + }, [setAppBadgeEnabled]); + + return { + showPermissionRationale, + showPermissionDeniedHelp, + isRequestingPermission, + declinePermissionRationale, + dismissPermissionDeniedHelp, + requestPermission, + }; } diff --git a/src/lib/preferencesStore.ts b/src/lib/preferencesStore.ts index 58bc92c..1a73821 100644 --- a/src/lib/preferencesStore.ts +++ b/src/lib/preferencesStore.ts @@ -105,6 +105,7 @@ export interface PreferencesState { // Display settings showFilenames: boolean; systemNameRegion: SystemNameRegionPreference; + appBadgeEnabled: boolean; // Accessibility settings hapticsEnabled: boolean; @@ -169,6 +170,7 @@ export interface PreferencesActions { setLogLevelFilters: (filters: PreferencesState["logLevelFilters"]) => void; setShowFilenames: (value: boolean) => void; setSystemNameRegion: (value: SystemNameRegionPreference) => void; + setAppBadgeEnabled: (value: boolean) => void; setHapticsEnabled: (value: boolean) => void; setTextZoomLevel: (value: number) => void; setAccessibleLists: (value: boolean) => void; @@ -219,6 +221,7 @@ const DEFAULT_PREFERENCES: Omit< }, showFilenames: false, systemNameRegion: "auto", + appBadgeEnabled: true, hapticsEnabled: true, textZoomLevel: 1.0, accessibleLists: false, @@ -343,6 +346,7 @@ export const usePreferencesStore = create()( setLogLevelFilters: (filters) => set({ logLevelFilters: filters }), setShowFilenames: (value) => set({ showFilenames: value }), setSystemNameRegion: (value) => set({ systemNameRegion: value }), + setAppBadgeEnabled: (value) => set({ appBadgeEnabled: value }), setHapticsEnabled: (value) => set({ hapticsEnabled: value }), setTextZoomLevel: (value) => set({ textZoomLevel: value }), setAccessibleLists: (value) => set({ accessibleLists: value }), @@ -369,6 +373,7 @@ export const usePreferencesStore = create()( logLevelFilters: state.logLevelFilters, showFilenames: state.showFilenames, systemNameRegion: state.systemNameRegion, + appBadgeEnabled: state.appBadgeEnabled, hapticsEnabled: state.hapticsEnabled, textZoomLevel: state.textZoomLevel, accessibleLists: state.accessibleLists, diff --git a/src/routes/settings.advanced.tsx b/src/routes/settings.advanced.tsx index 49b4d45..2450894 100644 --- a/src/routes/settings.advanced.tsx +++ b/src/routes/settings.advanced.tsx @@ -19,6 +19,7 @@ import { SlideModal } from "@/components/SlideModal"; import { appBackNavigationOptions } from "@/lib/tabSessionStore"; import { Button } from "@/components/wui/Button"; import { ClientCapability } from "@/lib/models"; +import { isNativePluginAvailable } from "@/lib/capacitorBridge"; import { isPurchasePreviewEnabled, usePurchasePreviewStore, @@ -41,6 +42,8 @@ export function AdvancedSettings() { ); const showFilenames = usePreferencesStore((s) => s.showFilenames); const setShowFilenames = usePreferencesStore((s) => s.setShowFilenames); + const appBadgeEnabled = usePreferencesStore((s) => s.appBadgeEnabled); + const setAppBadgeEnabled = usePreferencesStore((s) => s.setAppBadgeEnabled); const purchasePreviewState = usePurchasePreviewStore((state) => state.state); const setPurchasePreviewState = usePurchasePreviewStore( (state) => state.setPreviewState, @@ -153,6 +156,22 @@ export function AdvancedSettings() { setValue={setShowFilenames} /> + {isNativePluginAvailable("Badge") && ( + + {t("settings.advanced.appIconBadges")} + + + } + value={appBadgeEnabled} + setValue={setAppBadgeEnabled} + /> + )} + {isPurchasePreviewEnabled() && (