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..7ac1af0 --- /dev/null +++ b/src/__tests__/unit/components/AppBadgeManager.test.tsx @@ -0,0 +1,96 @@ +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 dismissPermissionRationale = vi.fn(); +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, + dismissPermissionRationale, + 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 dismiss the rationale without persisting opt-out", async () => { + const user = userEvent.setup(); + renderManager(); + + await user.click(screen.getAllByTestId("modal-overlay")[0]!); + + expect(dismissPermissionRationale).toHaveBeenCalledOnce(); + expect(declinePermissionRationale).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, + dismissPermissionRationale, + 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..d87f3c1 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,258 @@ 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 dismiss the rationale without disabling badges", 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.dismissPermissionRationale(); + }); + + expect(usePreferencesStore.getState().appBadgeEnabled).toBe(true); + expect(result.current.showPermissionRationale).toBe(false); + }); + + 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 preserve a re-enable transition across cancelled syncs", async () => { + usePreferencesStore.setState({ appBadgeEnabled: false }); + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(Badge.set).toHaveBeenCalledWith({ count: 0 }); + }); + + vi.clearAllMocks(); + let resolveSupport: ((value: { isSupported: boolean }) => void) | undefined; + const delayedSupport = new Promise<{ isSupported: boolean }>((resolve) => { + resolveSupport = resolve; + }); + vi.mocked(Badge.isSupported) + .mockImplementationOnce(() => delayedSupport) + .mockResolvedValue({ isSupported: true }); + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "prompt" }); + + act(() => { + usePreferencesStore.getState().setAppBadgeEnabled(true); + }); + await waitFor(() => { + expect(Badge.isSupported).toHaveBeenCalledOnce(); + }); + + act(() => { + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + }); + act(() => { + useStatusStore.setState({ inboxMessages: [] }); + }); + act(() => { + resolveSupport?.({ isSupported: true }); + }); + + await waitFor(() => { + expect(result.current.showPermissionRationale).toBe(true); + }); + }); + + it("should preserve denied help across cancelled re-enable syncs", async () => { + usePreferencesStore.setState({ appBadgeEnabled: false }); + const { result } = renderHook(() => useAppBadge()); + + await waitFor(() => { + expect(Badge.set).toHaveBeenCalledWith({ count: 0 }); + }); + + vi.clearAllMocks(); + let resolveSupport: ((value: { isSupported: boolean }) => void) | undefined; + const delayedSupport = new Promise<{ isSupported: boolean }>((resolve) => { + resolveSupport = resolve; + }); + vi.mocked(Badge.isSupported) + .mockImplementationOnce(() => delayedSupport) + .mockResolvedValue({ isSupported: true }); + vi.mocked(Badge.checkPermissions).mockResolvedValue({ display: "denied" }); + + act(() => { + usePreferencesStore.getState().setAppBadgeEnabled(true); + }); + await waitFor(() => { + expect(Badge.isSupported).toHaveBeenCalledOnce(); + }); + + act(() => { + useStatusStore.setState({ + inboxMessages: [mockInboxMessage({ id: 1 })], + }); + }); + act(() => { + useStatusStore.setState({ inboxMessages: [] }); + }); + act(() => { + resolveSupport?.({ isSupported: true }); + }); + + await waitFor(() => { + expect(result.current.showPermissionDeniedHelp).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..63c8a49 100644 --- a/src/__tests__/unit/routes/settings.advanced.test.tsx +++ b/src/__tests__/unit/routes/settings.advanced.test.tsx @@ -19,6 +19,12 @@ vi.mock("@/lib/coreApi", () => ({ // Mock stores const mockUseStatusStore = vi.fn(); const mockUsePreferencesStore = vi.fn(); +const { mockIsNativePluginAvailable, mockIsNativePlatform, mockGetPlatform } = + vi.hoisted(() => ({ + mockIsNativePluginAvailable: vi.fn(), + mockIsNativePlatform: vi.fn(), + mockGetPlatform: vi.fn(), + })); vi.mock("@/lib/store", async (importOriginal) => { const actual = (await importOriginal()) as any; @@ -32,6 +38,11 @@ vi.mock("@/lib/preferencesStore", () => ({ usePreferencesStore: (selector: any) => mockUsePreferencesStore(selector), })); +vi.mock("@/lib/capacitorBridge", () => ({ + isNativePluginAvailable: (pluginName: string) => + mockIsNativePlatform() && mockIsNativePluginAvailable(pluginName), +})); + // Mock router - use vi.hoisted to make variables accessible in mocks const { mockNavigate, componentRef } = vi.hoisted(() => ({ mockNavigate: vi.fn(), @@ -67,7 +78,8 @@ vi.mock("@/hooks/usePageHeadingFocus", () => ({ // Mock Capacitor vi.mock("@capacitor/core", () => ({ Capacitor: { - isNativePlatform: vi.fn(() => false), + isNativePlatform: mockIsNativePlatform, + getPlatform: mockGetPlatform, }, })); @@ -94,6 +106,8 @@ describe("Settings Advanced Route", () => { const defaultPreferencesState = { showFilenames: false, setShowFilenames: vi.fn(), + appBadgeEnabled: true, + setAppBadgeEnabled: vi.fn(), }; beforeEach(() => { @@ -113,6 +127,9 @@ describe("Settings Advanced Route", () => { readersAutoDetect: false, }); mockSettingsUpdate.mockResolvedValue({}); + mockIsNativePluginAvailable.mockReturnValue(false); + mockIsNativePlatform.mockReturnValue(false); + mockGetPlatform.mockReturnValue("web"); mockUseStatusStore.mockImplementation((selector) => selector(defaultStoreState), @@ -278,6 +295,47 @@ describe("Settings Advanced Route", () => { expect(showFilenames).toBeEnabled(); }); + it("should update app icon badge preference on iOS", async () => { + const user = userEvent.setup(); + const setAppBadgeEnabled = vi.fn(); + mockIsNativePlatform.mockReturnValue(true); + mockGetPlatform.mockReturnValue("ios"); + 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 hide the app icon badge preference on Android", () => { + mockIsNativePlatform.mockReturnValue(true); + mockGetPlatform.mockReturnValue("android"); + mockIsNativePluginAvailable.mockImplementation( + (pluginName: string) => pluginName === "Badge", + ); + + renderComponent(); + + expect( + screen.queryByRole("checkbox", { + name: /settings.advanced.appIconBadges/i, + }), + ).not.toBeInTheDocument(); + }); + 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..f4f2f74 --- /dev/null +++ b/src/components/AppBadgeManager.tsx @@ -0,0 +1,66 @@ +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, + dismissPermissionRationale, + 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..85e4ca0 100644 --- a/src/hooks/useAppBadge.ts +++ b/src/hooks/useAppBadge.ts @@ -1,14 +1,38 @@ -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 pendingEnableTransitionRef = useRef(false); + 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) { + pendingEnableTransitionRef.current = true; + rationaleHandledRef.current = false; + } else if (!appBadgeEnabled) { + pendingEnableTransitionRef.current = false; + } + if (!isNativePluginAvailable("Badge")) return; let cancelled = false; @@ -18,23 +42,49 @@ export function useAppBadge() { try { const support = await Badge.isSupported(); - if (!support.isSupported || cancelled) return; + if (cancelled) return; + if (!support.isSupported) { + pendingEnableTransitionRef.current = false; + return; + } - let permission = await Badge.checkPermissions(); + const permission = await Badge.checkPermissions(); if (cancelled) return; + const isEnableTransition = pendingEnableTransitionRef.current; + + if (!appBadgeEnabled) { + if (permission.display === "granted") { + await Badge.set({ count: 0 }); + } + return; + } + + if (permission.display === "granted") { + await Badge.set({ count: inboxCount }); + if (!cancelled) { + pendingEnableTransitionRef.current = false; + } + return; + } + if ( - inboxCount > 0 && - (permission.display === "prompt" || - permission.display === "prompt-with-rationale") + isPromptPermission(permission.display) && + (inboxCount > 0 || isEnableTransition) && + !rationaleHandledRef.current ) { - permission = await Badge.requestPermissions(); - if (cancelled) return; + setShowPermissionRationale(true); + pendingEnableTransitionRef.current = false; + return; } - if (permission.display !== "granted") return; - - await Badge.set({ count: inboxCount }); + if (permission.display === "denied") { + setAppBadgeEnabled(false); + if (isEnableTransition) { + setShowPermissionDeniedHelp(true); + } + } + pendingEnableTransitionRef.current = false; } catch (error) { if (cancelled) return; @@ -53,5 +103,56 @@ export function useAppBadge() { return () => { cancelled = true; }; - }, [inboxCount]); + }, [appBadgeEnabled, inboxCount, setAppBadgeEnabled]); + + const dismissPermissionRationale = useCallback(() => { + rationaleHandledRef.current = true; + setShowPermissionRationale(false); + }, []); + + const declinePermissionRationale = useCallback(() => { + dismissPermissionRationale(); + setAppBadgeEnabled(false); + }, [dismissPermissionRationale, 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, + dismissPermissionRationale, + 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..9ec1380 100644 --- a/src/routes/settings.advanced.tsx +++ b/src/routes/settings.advanced.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; +import { Capacitor } from "@capacitor/core"; import { CoreAPI } from "@/lib/coreApi"; import { ToggleSwitch } from "@/components/wui/ToggleSwitch"; import { SettingHelp } from "@/components/wui/SettingHelp"; @@ -19,6 +20,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 +43,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 +157,23 @@ export function AdvancedSettings() { setValue={setShowFilenames} /> + {Capacitor.getPlatform() === "ios" && + isNativePluginAvailable("Badge") && ( + + {t("settings.advanced.appIconBadges")} + + + } + value={appBadgeEnabled} + setValue={setAppBadgeEnabled} + /> + )} + {isPurchasePreviewEnabled() && (