diff --git a/app/account/tabs/__tests__/delete-tab-premium.test.tsx b/app/account/tabs/__tests__/delete-tab-premium.test.tsx
index 6b202bb..5fbafaf 100644
--- a/app/account/tabs/__tests__/delete-tab-premium.test.tsx
+++ b/app/account/tabs/__tests__/delete-tab-premium.test.tsx
@@ -18,6 +18,7 @@ import {
waitFor,
} from "@testing-library/react-native";
import React from "react";
+import { Alert } from "react-native";
import { useCydAccount } from "@/contexts/CydAccountProvider";
@@ -241,6 +242,7 @@ describe("DeleteTab Premium Integration", () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockCheckPremiumAccess.mockResolvedValue({ status: "signed_out" });
mockWithBlueskyController.mockImplementation(
async (_accountId, _accountUUID, fn) => {
return fn({
@@ -370,6 +372,7 @@ describe("DeleteTab Premium Integration", () => {
});
it("should show PremiumRequiredModal when signed in without premium", async () => {
+ mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" });
mockUseCydAccount.mockReturnValue({
state: {
isSignedIn: true,
@@ -476,6 +479,7 @@ describe("DeleteTab Premium Integration", () => {
});
// Confirm premium
+ mockCheckPremiumAccess.mockResolvedValue({ status: "premium" });
await act(async () => {
fireEvent.press(screen.getByTestId("premium-modal-confirm"));
});
@@ -490,6 +494,7 @@ describe("DeleteTab Premium Integration", () => {
describe("Delete My Data button - with premium", () => {
beforeEach(() => {
+ mockCheckPremiumAccess.mockResolvedValue({ status: "premium" });
mockUseCydAccount.mockReturnValue({
state: {
isSignedIn: true,
@@ -561,6 +566,25 @@ describe("DeleteTab Premium Integration", () => {
expect(screen.queryByTestId("premium-required-modal")).toBeNull();
});
+
+ it("blocks deletion when cached Premium access has expired", async () => {
+ mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Continue to Review")).toBeTruthy();
+ });
+ fireEvent.press(screen.getByText("Continue to Review"));
+ await waitFor(() => expect(screen.getByText("Delete My Data")).toBeTruthy());
+
+ fireEvent.press(screen.getByText("Delete My Data"));
+
+ await waitFor(() => {
+ expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId("premium-required-modal")).toBeTruthy();
+ });
+ expect(screen.queryByTestId("delete-automation-modal")).toBeNull();
+ });
});
describe("Continue to Review button", () => {
@@ -585,7 +609,12 @@ describe("DeleteTab Premium Integration", () => {
});
describe("premium check error handling", () => {
- it("should show PremiumRequiredModal when premium check fails with error", async () => {
+ it("should show a retryable error when premium verification fails", async () => {
+ const alertSpy = jest.spyOn(Alert, "alert").mockImplementation();
+ mockCheckPremiumAccess.mockResolvedValue({
+ status: "error",
+ message: "Server error",
+ });
mockUseCydAccount.mockReturnValue({
state: {
isSignedIn: true,
@@ -618,13 +647,19 @@ describe("DeleteTab Premium Integration", () => {
fireEvent.press(screen.getByText("Delete My Data"));
});
- // Should show premium modal (since hasPremiumAccess is false)
await waitFor(() => {
- expect(screen.getByTestId("premium-required-modal")).toBeTruthy();
+ expect(alertSpy).toHaveBeenCalledWith(
+ "Couldn’t verify Premium",
+ "Check your connection and try again.",
+ expect.any(Array),
+ );
});
+ expect(screen.queryByTestId("premium-required-modal")).toBeNull();
});
- it("should show PremiumRequiredModal when premium check throws", async () => {
+ it("fails closed when the premium check throws", async () => {
+ const alertSpy = jest.spyOn(Alert, "alert").mockImplementation();
+ mockCheckPremiumAccess.mockRejectedValue(new Error("Network error"));
mockUseCydAccount.mockReturnValue({
state: {
isSignedIn: true,
@@ -657,10 +692,10 @@ describe("DeleteTab Premium Integration", () => {
fireEvent.press(screen.getByText("Delete My Data"));
});
- // Should show premium modal (since hasPremiumAccess is false)
await waitFor(() => {
- expect(screen.getByTestId("premium-required-modal")).toBeTruthy();
+ expect(alertSpy).toHaveBeenCalled();
});
+ expect(screen.queryByTestId("delete-automation-modal")).toBeNull();
});
});
});
diff --git a/app/account/tabs/__tests__/schedule-tab-premium.test.tsx b/app/account/tabs/__tests__/schedule-tab-premium.test.tsx
new file mode 100644
index 0000000..df7456b
--- /dev/null
+++ b/app/account/tabs/__tests__/schedule-tab-premium.test.tsx
@@ -0,0 +1,289 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react-native";
+import React from "react";
+
+import { Colors } from "@/constants/theme";
+import { useCydAccount } from "@/contexts/CydAccountProvider";
+import { getAccountDeleteSettings } from "@/database/delete-settings";
+
+import { ScheduleTab } from "../schedule-tab";
+
+const mockCheckPremiumAccess = jest.fn();
+
+jest.mock("expo-router", () => ({
+ useLocalSearchParams: () => ({ scheduleShowReview: "true" }),
+}));
+jest.mock("@react-native-community/datetimepicker", () => () => null);
+jest.mock("expo-localization", () => ({
+ getCalendars: () => [{ timeZone: "UTC" }],
+}));
+jest.mock("@/services/push-notifications", () => ({
+ registerForPushNotifications: jest.fn(),
+}));
+
+jest.mock("@/contexts/CydAccountProvider", () => ({
+ useCydAccount: jest.fn(),
+}));
+
+jest.mock("@/database/accounts", () => ({
+ getAccountHandle: jest.fn().mockResolvedValue("alice.test"),
+ getLastDeletedAt: jest.fn().mockResolvedValue(Date.now()),
+ getLastSavedAt: jest.fn().mockResolvedValue(Date.now()),
+ setLastDeletedAt: jest.fn().mockResolvedValue(undefined),
+ setLastSavedAt: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock("@/database/schedule-settings", () => ({
+ getAccountScheduleSettings: jest.fn().mockResolvedValue({
+ scheduleDeletion: true,
+ scheduleDeletionFrequency: "weekly",
+ scheduleDeletionDayOfMonth: 1,
+ scheduleDeletionDayOfWeek: 0,
+ scheduleDeletionTime: "09:00",
+ }),
+ updateAccountScheduleSettings: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock("@/database/save-settings", () => ({
+ getAccountSaveSettings: jest.fn().mockResolvedValue({
+ posts: true,
+ likes: true,
+ bookmarks: true,
+ chat: true,
+ }),
+}));
+
+jest.mock("@/database/delete-settings", () => ({
+ getAccountDeleteSettings: jest.fn(),
+}));
+
+jest.mock("@/app/account/components/SaveReviewList", () => ({
+ SaveReviewList: () => null,
+}));
+jest.mock("@/app/account/components/DeleteReviewList", () => ({
+ DeleteReviewList: () => null,
+}));
+jest.mock("@/components/PremiumRequiredBanner", () => ({
+ PremiumRequiredBanner: () => null,
+}));
+jest.mock("@/components/PremiumRequiredModal", () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
+ const { Pressable, Text } = require("react-native");
+ return {
+ PremiumRequiredModal: ({
+ visible,
+ onPremiumConfirmed,
+ }: {
+ visible: boolean;
+ onPremiumConfirmed: () => void;
+ }) =>
+ visible ? (
+
+ Premium required
+
+ ) : null,
+ };
+});
+jest.mock("@/components/SaveAndDeleteStatusBanner", () => ({
+ SaveAndDeleteStatusBanner: () => null,
+}));
+jest.mock("@/components/LastActionTimestamp", () => ({
+ LastActionTimestamp: () => null,
+}));
+jest.mock("@/app/account/components/FinishedModal", () => ({
+ FinishedModal: () => null,
+}));
+jest.mock("@/app/account/components/ScheduledAutomationModal", () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
+ const { Text } = require("react-native");
+ return {
+ ScheduledAutomationModal: ({ visible }: { visible: boolean }) =>
+ visible ? Automation running : null,
+ };
+});
+
+const mockUseCydAccount = useCydAccount as jest.Mock;
+const mockGetAccountDeleteSettings = getAccountDeleteSettings as jest.Mock;
+
+const noDeletes = {
+ deletePosts: false,
+ deletePostsDaysOldEnabled: false,
+ deletePostsDaysOld: 0,
+ deletePostsLikesThresholdEnabled: false,
+ deletePostsLikesThreshold: 0,
+ deletePostsRepostsThresholdEnabled: false,
+ deletePostsRepostsThreshold: 0,
+ deletePostsPreserveThreads: false,
+ deleteReposts: false,
+ deleteRepostsDaysOldEnabled: false,
+ deleteRepostsDaysOld: 0,
+ deleteLikes: false,
+ deleteLikesDaysOldEnabled: false,
+ deleteLikesDaysOld: 0,
+ deleteBookmarks: false,
+ deleteChats: false,
+ deleteChatsDaysOldEnabled: false,
+ deleteChatsDaysOld: 0,
+ deleteUnfollowEveryone: false,
+};
+
+describe("ScheduleTab Premium execution gate", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseCydAccount.mockReturnValue({
+ state: {
+ isSignedIn: true,
+ userEmail: "alice@example.com",
+ isLoading: false,
+ hasPremiumAccess: true,
+ },
+ apiClient: {},
+ checkPremiumAccess: mockCheckPremiumAccess,
+ });
+ });
+
+ it("blocks a notification-launched run when cached Premium has expired", async () => {
+ mockGetAccountDeleteSettings.mockResolvedValue({
+ ...noDeletes,
+ deletePosts: true,
+ });
+ mockCheckPremiumAccess.mockResolvedValue({ status: "not_premium" });
+
+ render(
+ ,
+ );
+
+ await waitFor(() =>
+ expect(
+ screen.getByText("Ready to save and delete your Bluesky data?"),
+ ).toBeTruthy(),
+ );
+ fireEvent.press(screen.getByText("Save and Delete Data Now"));
+
+ await waitFor(() => expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1));
+ expect(screen.getByTestId("premium-required-modal")).toBeTruthy();
+ expect(screen.queryByTestId("scheduled-automation")).toBeNull();
+ });
+
+ it("starts a free-only run without checking Premium or requiring Cyd sign-in", async () => {
+ mockGetAccountDeleteSettings.mockResolvedValue(noDeletes);
+ mockUseCydAccount.mockReturnValue({
+ state: {
+ isSignedIn: false,
+ userEmail: null,
+ isLoading: false,
+ hasPremiumAccess: null,
+ },
+ apiClient: {},
+ checkPremiumAccess: mockCheckPremiumAccess,
+ });
+
+ render(
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(),
+ );
+ fireEvent.press(screen.getByText("Save and Delete Data Now"));
+
+ await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy());
+ expect(mockCheckPremiumAccess).not.toHaveBeenCalled();
+ });
+
+ it("starts a Premium run only after a fresh successful check", async () => {
+ mockGetAccountDeleteSettings.mockResolvedValue({
+ ...noDeletes,
+ deleteLikes: true,
+ });
+ mockCheckPremiumAccess.mockResolvedValue({ status: "premium" });
+
+ render(
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(),
+ );
+ fireEvent.press(screen.getByText("Save and Delete Data Now"));
+
+ await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy());
+ expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1);
+ });
+
+ it("re-verifies and resumes the retained run after Premium is confirmed", async () => {
+ mockGetAccountDeleteSettings.mockResolvedValue({
+ ...noDeletes,
+ deleteBookmarks: true,
+ });
+ mockCheckPremiumAccess
+ .mockResolvedValueOnce({ status: "not_premium" })
+ .mockResolvedValueOnce({ status: "premium" });
+
+ render(
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(),
+ );
+ fireEvent.press(screen.getByText("Save and Delete Data Now"));
+ await waitFor(() =>
+ expect(screen.getByTestId("premium-required-modal")).toBeTruthy(),
+ );
+
+ fireEvent.press(screen.getByTestId("premium-required-modal"));
+
+ await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy());
+ expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(2);
+ });
+
+ it("coalesces repeated taps while verification is in flight", async () => {
+ mockGetAccountDeleteSettings.mockResolvedValue({
+ ...noDeletes,
+ deleteChats: true,
+ });
+ let resolveCheck!: (value: { status: "premium" }) => void;
+ mockCheckPremiumAccess.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCheck = resolve;
+ }),
+ );
+
+ render(
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("Save and Delete Data Now")).toBeTruthy(),
+ );
+ const startButton = screen.getByText("Save and Delete Data Now");
+ fireEvent.press(startButton);
+ fireEvent.press(startButton);
+
+ expect(mockCheckPremiumAccess).toHaveBeenCalledTimes(1);
+ resolveCheck({ status: "premium" });
+ await waitFor(() => expect(screen.getByTestId("scheduled-automation")).toBeTruthy());
+ });
+});
diff --git a/app/account/tabs/delete-tab.tsx b/app/account/tabs/delete-tab.tsx
index a870cc3..dd40046 100644
--- a/app/account/tabs/delete-tab.tsx
+++ b/app/account/tabs/delete-tab.tsx
@@ -1,7 +1,8 @@
import { MaterialIcons } from "@expo/vector-icons";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
+ Alert,
Pressable,
ScrollView,
Text,
@@ -29,6 +30,7 @@ import { useCydAccount } from "@/contexts/CydAccountProvider";
import { withBlueskyController } from "@/controllers";
import type { DeletionPreviewCounts } from "@/controllers/bluesky/deletion-calculator";
import type { BlueskyJobRecord } from "@/controllers/bluesky/job-types";
+import { deleteSettingsRequirePremium } from "@/controllers/bluesky/premium-feature-policy";
import { getLastSavedAt, setLastDeletedAt } from "@/database/accounts";
import {
getAccountDeleteSettings,
@@ -51,7 +53,7 @@ export function DeleteTab({
palette,
onSelectTab,
}: AccountTabProps) {
- const { apiClient } = useCydAccount();
+ const { apiClient, checkPremiumAccess } = useCydAccount();
const [screenStack, setScreenStack] = useState(["form"]);
const [state, setState] = useState(null);
const [loading, setLoading] = useState(true);
@@ -198,20 +200,13 @@ export function DeleteTab({
// Premium modal state
const [premiumModalVisible, setPremiumModalVisible] = useState(false);
+ const [checkingPremium, setCheckingPremium] = useState(false);
+ const premiumCheckInFlightRef = useRef(false);
const [pendingDeleteSettings, setPendingDeleteSettings] =
useState(null);
const [pendingDeleteCounts, setPendingDeleteCounts] =
useState(null);
- const handlePremiumRequired = useCallback(
- (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => {
- setPendingDeleteSettings(settings);
- setPendingDeleteCounts(counts);
- setPremiumModalVisible(true);
- },
- [],
- );
-
const handlePremiumDismiss = useCallback(() => {
setPremiumModalVisible(false);
setPendingDeleteSettings(null);
@@ -220,27 +215,7 @@ export function DeleteTab({
resetToForm();
}, [resetToForm]);
- const handlePremiumConfirmed = useCallback(() => {
- setPremiumModalVisible(false);
- // Start deletion with the pending settings and counts
- if (pendingDeleteSettings && pendingDeleteCounts) {
- setAutomationSettings(pendingDeleteSettings);
- setAutomationCounts({
- posts: pendingDeleteCounts.posts,
- reposts: pendingDeleteCounts.reposts,
- likes: pendingDeleteCounts.likes,
- bookmarks: pendingDeleteCounts.bookmarks,
- messages: pendingDeleteCounts.messages,
- follows: pendingDeleteCounts.follows,
- });
- setAutomationKey((prev) => prev + 1);
- setAutomationVisible(true);
- }
- setPendingDeleteSettings(null);
- setPendingDeleteCounts(null);
- }, [pendingDeleteSettings, pendingDeleteCounts]);
-
- const handleStartDelete = useCallback(
+ const startDeleteAutomation = useCallback(
(settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => {
setAutomationSettings(settings);
setAutomationCounts({
@@ -257,6 +232,89 @@ export function DeleteTab({
[],
);
+ const verifyAndStartDeleteRef = useRef<
+ (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => void
+ >(() => undefined);
+
+ const verifyAndStartDelete = useCallback(
+ async (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => {
+ if (premiumCheckInFlightRef.current) return;
+
+ if (!deleteSettingsRequirePremium(settings)) {
+ startDeleteAutomation(settings, counts);
+ return;
+ }
+
+ setPendingDeleteSettings(settings);
+ setPendingDeleteCounts(counts);
+ premiumCheckInFlightRef.current = true;
+ setCheckingPremium(true);
+
+ const showVerificationError = () => {
+ Alert.alert(
+ "Couldn’t verify Premium",
+ "Check your connection and try again.",
+ [
+ {
+ text: "Cancel",
+ style: "cancel",
+ onPress: () => {
+ setPendingDeleteSettings(null);
+ setPendingDeleteCounts(null);
+ },
+ },
+ {
+ text: "Retry",
+ onPress: () =>
+ verifyAndStartDeleteRef.current(settings, counts),
+ },
+ ],
+ );
+ };
+
+ try {
+ const result = await checkPremiumAccess();
+ if (result.status === "premium") {
+ setPremiumModalVisible(false);
+ setPendingDeleteSettings(null);
+ setPendingDeleteCounts(null);
+ startDeleteAutomation(settings, counts);
+ } else if (
+ result.status === "not_premium" ||
+ result.status === "signed_out"
+ ) {
+ setPremiumModalVisible(true);
+ } else {
+ showVerificationError();
+ }
+ } catch {
+ showVerificationError();
+ } finally {
+ premiumCheckInFlightRef.current = false;
+ setCheckingPremium(false);
+ }
+ },
+ [checkPremiumAccess, startDeleteAutomation],
+ );
+ useEffect(() => {
+ verifyAndStartDeleteRef.current = (settings, counts) => {
+ void verifyAndStartDelete(settings, counts);
+ };
+ }, [verifyAndStartDelete]);
+
+ const handlePremiumConfirmed = useCallback(() => {
+ if (pendingDeleteSettings && pendingDeleteCounts) {
+ void verifyAndStartDelete(pendingDeleteSettings, pendingDeleteCounts);
+ }
+ }, [pendingDeleteSettings, pendingDeleteCounts, verifyAndStartDelete]);
+
+ const handleStartDelete = useCallback(
+ (settings: AccountDeleteSettings, counts: DeletionPreviewCounts) => {
+ void verifyAndStartDelete(settings, counts);
+ },
+ [verifyAndStartDelete],
+ );
+
const showFinishedModalWithJobs = useCallback(
(jobs: BlueskyJobRecord[]) => {
// Submit progress to the server regardless of success/failure
@@ -353,7 +411,7 @@ export function DeleteTab({
selections={state}
onBack={popScreen}
onConfirm={handleStartDelete}
- onPremiumRequired={handlePremiumRequired}
+ checkingPremium={checkingPremium}
refreshKey={refreshKey}
/>
)}
@@ -754,10 +812,7 @@ type DeleteReviewScreenProps = {
settings: AccountDeleteSettings,
counts: DeletionPreviewCounts,
) => void;
- onPremiumRequired: (
- settings: AccountDeleteSettings,
- counts: DeletionPreviewCounts,
- ) => void;
+ checkingPremium: boolean;
refreshKey: number;
};
@@ -795,10 +850,9 @@ function DeleteReviewScreen({
selections,
onBack,
onConfirm,
- onPremiumRequired,
+ checkingPremium,
refreshKey: externalRefreshKey,
}: DeleteReviewScreenProps) {
- const { state: cydState } = useCydAccount();
const [counts, setCounts] = useState(null);
const [countsLoading, setCountsLoading] = useState(true);
const [countsError, setCountsError] = useState(null);
@@ -867,32 +921,11 @@ function DeleteReviewScreen({
setRefreshKey((prev) => prev + 1);
}, []);
- // Handle Delete My Data button - check premium first
+ // The parent owns the fresh entitlement check at the execution boundary.
const handleDeletePress = useCallback(() => {
if (!counts) return;
-
- // If not signed in, show premium modal
- if (!cydState.isSignedIn) {
- onPremiumRequired(selections, counts);
- return;
- }
-
- // Check if user has premium (from context state)
- if (cydState.hasPremiumAccess === true) {
- // Has premium, proceed with deletion
- onConfirm(selections, counts);
- } else {
- // No premium or unknown, show the modal
- onPremiumRequired(selections, counts);
- }
- }, [
- counts,
- cydState.isSignedIn,
- cydState.hasPremiumAccess,
- selections,
- onConfirm,
- onPremiumRequired,
- ]);
+ onConfirm(selections, counts);
+ }, [counts, selections, onConfirm]);
type DeletionItem = {
label: string;
@@ -1121,9 +1154,9 @@ function DeleteReviewScreen({
palette={palette}
/>
diff --git a/app/account/tabs/schedule-tab.tsx b/app/account/tabs/schedule-tab.tsx
index 40979b3..1ba09e5 100644
--- a/app/account/tabs/schedule-tab.tsx
+++ b/app/account/tabs/schedule-tab.tsx
@@ -2,7 +2,7 @@ import { MaterialIcons } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as Localization from "expo-localization";
import { useLocalSearchParams } from "expo-router";
-import React, { useCallback, useEffect, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -30,12 +30,14 @@ import {
} from "@/components/account/shared-tab-styles";
import { LastActionTimestamp } from "@/components/LastActionTimestamp";
import { PremiumRequiredBanner } from "@/components/PremiumRequiredBanner";
+import { PremiumRequiredModal } from "@/components/PremiumRequiredModal";
import { SaveAndDeleteStatusBanner } from "@/components/SaveAndDeleteStatusBanner";
import { useCydAccount } from "@/contexts/CydAccountProvider";
import type {
BlueskyJobRecord,
SaveAndDeleteJobOptions,
} from "@/controllers/bluesky/job-types";
+import { saveAndDeleteOptionsRequirePremium } from "@/controllers/bluesky/premium-feature-policy";
import {
getAccountHandle,
getLastDeletedAt,
@@ -89,7 +91,7 @@ export function ScheduleTab({
palette,
onSelectTab,
}: AccountTabProps) {
- const { apiClient, state: cydState } = useCydAccount();
+ const { apiClient, state: cydState, checkPremiumAccess } = useCydAccount();
const params = useLocalSearchParams<{ scheduleShowReview?: string }>();
const showReviewOnLoad = params.scheduleShowReview === "true";
const [screenStack, setScreenStack] = useState([
@@ -114,6 +116,11 @@ export function ScheduleTab({
const [automationOptions, setAutomationOptions] =
useState(null);
const [automationKey, setAutomationKey] = useState(0);
+ const [checkingPremium, setCheckingPremium] = useState(false);
+ const [premiumModalVisible, setPremiumModalVisible] = useState(false);
+ const [pendingAutomationOptions, setPendingAutomationOptions] =
+ useState(null);
+ const premiumCheckInFlightRef = useRef(false);
// Finished modal state
const [finishedModalVisible, setFinishedModalVisible] = useState(false);
@@ -333,7 +340,7 @@ export function ScheduleTab({
setAutomationOptions(null);
}, []);
- const handleStartAutomation = useCallback(
+ const startAutomation = useCallback(
(options: SaveAndDeleteJobOptions) => {
setAutomationOptions(options);
setAutomationKey((prev) => prev + 1);
@@ -342,6 +349,88 @@ export function ScheduleTab({
[],
);
+ const verifyAndStartAutomationRef = useRef<
+ (options: SaveAndDeleteJobOptions) => void
+ >(() => undefined);
+
+ const verifyAndStartAutomation = useCallback(
+ async (options: SaveAndDeleteJobOptions) => {
+ if (premiumCheckInFlightRef.current) return;
+
+ if (!saveAndDeleteOptionsRequirePremium(options)) {
+ startAutomation(options);
+ return;
+ }
+
+ setPendingAutomationOptions(options);
+ premiumCheckInFlightRef.current = true;
+ setCheckingPremium(true);
+
+ const showVerificationError = () => {
+ Alert.alert(
+ "Couldn’t verify Premium",
+ "Check your connection and try again.",
+ [
+ {
+ text: "Cancel",
+ style: "cancel",
+ onPress: () => setPendingAutomationOptions(null),
+ },
+ {
+ text: "Retry",
+ onPress: () => verifyAndStartAutomationRef.current(options),
+ },
+ ],
+ );
+ };
+
+ try {
+ const result = await checkPremiumAccess();
+ if (result.status === "premium") {
+ setPremiumModalVisible(false);
+ setPendingAutomationOptions(null);
+ startAutomation(options);
+ } else if (
+ result.status === "not_premium" ||
+ result.status === "signed_out"
+ ) {
+ setPremiumModalVisible(true);
+ } else {
+ showVerificationError();
+ }
+ } catch {
+ showVerificationError();
+ } finally {
+ premiumCheckInFlightRef.current = false;
+ setCheckingPremium(false);
+ }
+ },
+ [checkPremiumAccess, startAutomation],
+ );
+ useEffect(() => {
+ verifyAndStartAutomationRef.current = (options) => {
+ void verifyAndStartAutomation(options);
+ };
+ }, [verifyAndStartAutomation]);
+
+ const handleStartAutomation = useCallback(
+ (options: SaveAndDeleteJobOptions) => {
+ void verifyAndStartAutomation(options);
+ },
+ [verifyAndStartAutomation],
+ );
+
+ const handlePremiumDismiss = useCallback(() => {
+ setPremiumModalVisible(false);
+ setPendingAutomationOptions(null);
+ }, []);
+
+ const handlePremiumConfirmed = useCallback(() => {
+ if (pendingAutomationOptions) {
+ void verifyAndStartAutomation(pendingAutomationOptions);
+ }
+ }, [pendingAutomationOptions, verifyAndStartAutomation]);
+
const showFinishedModalWithJobs = useCallback(
(jobs: BlueskyJobRecord[]) => {
// Update timestamps based on completed jobs
@@ -442,6 +531,7 @@ export function ScheduleTab({
onBack={popScreen}
onSelectTab={onSelectTab}
onStartAutomation={handleStartAutomation}
+ checkingPremium={checkingPremium}
/>
)}
{automationOptions && (
@@ -459,6 +549,12 @@ export function ScheduleTab({
onRestart={handleAutomationRestart}
/>
)}
+
void;
onSelectTab?: (tab: AccountTabKey) => void;
onStartAutomation: (options: SaveAndDeleteJobOptions) => void;
+ checkingPremium: boolean;
};
function ScheduleReviewScreen({
@@ -730,6 +827,7 @@ function ScheduleReviewScreen({
onBack,
onSelectTab,
onStartAutomation,
+ checkingPremium,
}: ScheduleReviewScreenProps) {
const handleSaveAndDelete = useCallback(() => {
const options: SaveAndDeleteJobOptions = {
@@ -814,8 +912,13 @@ function ScheduleReviewScreen({
palette={palette}
/>
diff --git a/components/PremiumRequiredModal.tsx b/components/PremiumRequiredModal.tsx
index 1a24b21..8ee0863 100644
--- a/components/PremiumRequiredModal.tsx
+++ b/components/PremiumRequiredModal.tsx
@@ -74,7 +74,13 @@ export function PremiumRequiredModal({
setCheckingPremium(true);
void (async () => {
try {
- await checkPremiumAccess();
+ const result = await checkPremiumAccess();
+ if (result.status === "error") {
+ Alert.alert(
+ "Couldn’t verify Premium",
+ "Check your connection and try again.",
+ );
+ }
// The useEffect above will call onPremiumConfirmed if hasPremiumAccess becomes true
// We just need to show appropriate feedback
setCheckingPremium(false);
@@ -117,7 +123,14 @@ export function PremiumRequiredModal({
const handleSignInClose = useCallback(() => {
setShowSignInModal(false);
// Re-check premium after sign-in modal closes
- void checkPremiumAccess();
+ void checkPremiumAccess().then((result) => {
+ if (result.status === "error") {
+ Alert.alert(
+ "Couldn’t verify Premium",
+ "Check your connection and try again.",
+ );
+ }
+ });
}, [checkPremiumAccess]);
const renderContent = () => {
@@ -139,7 +152,8 @@ export function PremiumRequiredModal({
<>
- Deleting data requires a Premium account. Sign in to get started.
+ Selected features require a Premium account. Sign in to get
+ started.
{usesAppStoreIAP
- ? "Deleting data requires a Premium account."
- : "Deleting data requires a Premium account. Manage your account to upgrade to Premium."}
+ ? "Selected features require a Premium account."
+ : "Selected features require a Premium account. Manage your account to upgrade to Premium."}
{usesAppStoreIAP ? (
diff --git a/components/__tests__/PremiumRequiredModal.test.tsx b/components/__tests__/PremiumRequiredModal.test.tsx
index aeee997..577d9ad 100644
--- a/components/__tests__/PremiumRequiredModal.test.tsx
+++ b/components/__tests__/PremiumRequiredModal.test.tsx
@@ -108,6 +108,7 @@ describe("PremiumRequiredModal", () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockCheckPremiumAccess.mockResolvedValue({ status: "signed_out" });
// Reset to default signed out state
mockUseCydAccount.mockReturnValue({
state: {
@@ -178,7 +179,7 @@ describe("PremiumRequiredModal", () => {
expect(
screen.getByText(
- "Deleting data requires a Premium account. Sign in to get started.",
+ "Selected features require a Premium account. Sign in to get started.",
),
).toBeTruthy();
expect(screen.getByText("Sign In")).toBeTruthy();
@@ -251,7 +252,7 @@ describe("PremiumRequiredModal", () => {
// Use partial match since text spans multiple lines
expect(
- screen.getByText(/Deleting data requires a Premium account/i),
+ screen.getByText(/Selected features require a Premium account/i),
).toBeTruthy();
});
diff --git a/contexts/CydAccountProvider.tsx b/contexts/CydAccountProvider.tsx
index 73596c4..76cec22 100644
--- a/contexts/CydAccountProvider.tsx
+++ b/contexts/CydAccountProvider.tsx
@@ -75,6 +75,12 @@ export type PremiumActionResult = {
error?: string;
};
+export type PremiumAccessCheckResult =
+ | { status: "premium" }
+ | { status: "not_premium" }
+ | { status: "signed_out" }
+ | { status: "error"; message: string };
+
export type CydAccountContextType = {
state: CydAccountState;
apiClient: CydAPIClient;
@@ -91,7 +97,7 @@ export type CydAccountContextType = {
) => Promise<{ success: boolean; error?: string }>;
refreshState: () => Promise;
getDashboardURL: () => string;
- checkPremiumAccess: () => Promise;
+ checkPremiumAccess: () => Promise;
purchasePremium: (
billingPeriod: BillingPeriod,
) => Promise;
@@ -345,20 +351,59 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
const checkPremiumAccess = useCallback(async () => {
if (!state.isSignedIn) {
setState((prev) => ({ ...prev, hasPremiumAccess: false }));
- return;
+ return { status: "signed_out" } as const;
}
try {
+ const authentication = await apiClient.refreshAuthentication();
+ if (authentication !== true) {
+ if (authentication.status === 401 || authentication.status === 403) {
+ await clearCydAccountCredentials();
+ apiClient.setCredentials(null, null);
+ setState((prev) => ({
+ ...prev,
+ isSignedIn: false,
+ userEmail: null,
+ hasPremiumAccess: false,
+ }));
+ return { status: "signed_out" } as const;
+ }
+ return {
+ status: "error",
+ message: authentication.message,
+ } as const;
+ }
+
const response = await apiClient.getUserPremium();
if ("error" in response) {
- setState((prev) => ({ ...prev, hasPremiumAccess: false }));
+ if (response.status === 401 || response.status === 403) {
+ await clearCydAccountCredentials();
+ apiClient.setCredentials(null, null);
+ setState((prev) => ({
+ ...prev,
+ isSignedIn: false,
+ userEmail: null,
+ hasPremiumAccess: false,
+ }));
+ return { status: "signed_out" } as const;
+ }
+ return { status: "error", message: response.message } as const;
} else {
setState((prev) => ({
...prev,
hasPremiumAccess: response.premium_access,
}));
+ return {
+ status: response.premium_access ? "premium" : "not_premium",
+ } as const;
}
- } catch {
- setState((prev) => ({ ...prev, hasPremiumAccess: false }));
+ } catch (error) {
+ return {
+ status: "error",
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to check Premium access.",
+ } as const;
}
}, [state.isSignedIn, apiClient]);
diff --git a/contexts/__tests__/CydAccountProvider.test.tsx b/contexts/__tests__/CydAccountProvider.test.tsx
index e9ef167..3d3072d 100644
--- a/contexts/__tests__/CydAccountProvider.test.tsx
+++ b/contexts/__tests__/CydAccountProvider.test.tsx
@@ -11,7 +11,10 @@ import {
} from "expo-iap";
import React from "react";
-import { getCydAccountCredentials } from "@/database/cyd-account";
+import {
+ clearCydAccountCredentials,
+ getCydAccountCredentials,
+} from "@/database/cyd-account";
import {
CydAccountProvider,
type CydAccountContextType,
@@ -20,6 +23,8 @@ import {
const mockSyncAppStoreSubscription = jest.fn();
const mockGetAppStoreSubscription = jest.fn();
+const mockGetUserPremium = jest.fn();
+const mockRefreshAuthentication = jest.fn();
const mockPing = jest.fn(() => Promise.resolve(false));
jest.mock("@/constants/subscriptions", () => ({
@@ -58,12 +63,25 @@ jest.mock("@/services/cyd-api-client", () => {
postUserActivity: jest.fn(() => Promise.resolve(true)),
deleteDevice: jest.fn(() => Promise.resolve()),
getDashboardURL: jest.fn(() => "https://dash.cyd.social"),
+ getUserPremium: mockGetUserPremium,
+ refreshAuthentication: mockRefreshAuthentication,
getAppStoreSubscription: mockGetAppStoreSubscription,
syncAppStoreSubscription: mockSyncAppStoreSubscription,
}));
});
describe("CydAccountProvider", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockPing.mockResolvedValue(false);
+ mockRefreshAuthentication.mockResolvedValue(true);
+ (getCydAccountCredentials as jest.Mock).mockResolvedValue({
+ userEmail: null,
+ deviceToken: null,
+ deviceUUID: null,
+ });
+ });
+
describe("exports", () => {
it("should export CydAccountProvider component", () => {
expect(CydAccountProvider).toBeDefined();
@@ -213,4 +231,158 @@ describe("CydAccountProvider", () => {
});
});
});
+
+ describe("Premium access verification", () => {
+ it("returns the fresh non-Premium result instead of cached access", async () => {
+ mockPing.mockResolvedValue(true);
+ (getCydAccountCredentials as jest.Mock).mockResolvedValue({
+ userEmail: "expired@example.com",
+ deviceToken: "device-token",
+ deviceUUID: "device-uuid",
+ });
+ mockGetUserPremium.mockResolvedValue({ premium_access: false });
+
+ let context: CydAccountContextType | null = null;
+ function ContextReader() {
+ context = useCydAccount();
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(context?.state.isSignedIn).toBe(true));
+
+ let result;
+ await act(async () => {
+ result = await context?.checkPremiumAccess();
+ });
+
+ expect(result).toEqual({ status: "not_premium" });
+ expect(context?.state.hasPremiumAccess).toBe(false);
+ });
+
+ it("signs out and clears credentials when the server rejects the device session", async () => {
+ mockPing.mockResolvedValue(true);
+ (getCydAccountCredentials as jest.Mock).mockResolvedValue({
+ userEmail: "revoked@example.com",
+ deviceToken: "revoked-device-token",
+ deviceUUID: "device-uuid",
+ });
+ mockGetUserPremium.mockResolvedValue({
+ error: true,
+ message: "Authentication failed",
+ status: 401,
+ });
+
+ let context: CydAccountContextType | null = null;
+ function ContextReader() {
+ context = useCydAccount();
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(context?.state.isSignedIn).toBe(true));
+
+ let result;
+ await act(async () => {
+ result = await context?.checkPremiumAccess();
+ });
+
+ expect(result).toEqual({ status: "signed_out" });
+ expect(clearCydAccountCredentials).toHaveBeenCalled();
+ expect(context?.state).toMatchObject({
+ isSignedIn: false,
+ userEmail: null,
+ hasPremiumAccess: false,
+ });
+ });
+
+ it("refreshes device authentication before checking Premium access", async () => {
+ mockPing.mockResolvedValue(true);
+ (getCydAccountCredentials as jest.Mock).mockResolvedValue({
+ userEmail: "revoked@example.com",
+ deviceToken: "revoked-device-token",
+ deviceUUID: "device-uuid",
+ });
+ mockRefreshAuthentication.mockResolvedValue({
+ error: true,
+ message: "Failed to get token with the server.",
+ status: 401,
+ });
+ mockGetUserPremium.mockResolvedValue({ premium_access: true });
+
+ let context: CydAccountContextType | null = null;
+ function ContextReader() {
+ context = useCydAccount();
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(context?.state.isSignedIn).toBe(true));
+
+ let result;
+ await act(async () => {
+ result = await context?.checkPremiumAccess();
+ });
+
+ expect(result).toEqual({ status: "signed_out" });
+ expect(mockRefreshAuthentication).toHaveBeenCalledTimes(1);
+ expect(mockGetUserPremium).not.toHaveBeenCalled();
+ expect(clearCydAccountCredentials).toHaveBeenCalled();
+ });
+
+ it("preserves the signed-in state when verification fails temporarily", async () => {
+ mockPing.mockResolvedValue(true);
+ (getCydAccountCredentials as jest.Mock).mockResolvedValue({
+ userEmail: "subscriber@example.com",
+ deviceToken: "device-token",
+ deviceUUID: "device-uuid",
+ });
+ mockGetUserPremium.mockResolvedValue({
+ error: true,
+ message: "Premium service unavailable",
+ status: 503,
+ });
+
+ let context: CydAccountContextType | null = null;
+ function ContextReader() {
+ context = useCydAccount();
+ return null;
+ }
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(context?.state.isSignedIn).toBe(true));
+
+ let result;
+ await act(async () => {
+ result = await context?.checkPremiumAccess();
+ });
+
+ expect(result).toEqual({
+ status: "error",
+ message: "Premium service unavailable",
+ });
+ expect(context?.state).toMatchObject({
+ isSignedIn: true,
+ userEmail: "subscriber@example.com",
+ hasPremiumAccess: null,
+ });
+ });
+ });
});
diff --git a/controllers/bluesky/__tests__/premium-feature-policy.test.ts b/controllers/bluesky/__tests__/premium-feature-policy.test.ts
new file mode 100644
index 0000000..88fcac1
--- /dev/null
+++ b/controllers/bluesky/__tests__/premium-feature-policy.test.ts
@@ -0,0 +1,75 @@
+import type { AccountDeleteSettings } from "@/database/delete-settings";
+
+import {
+ deleteSettingsRequirePremium,
+ saveAndDeleteOptionsRequirePremium,
+} from "../premium-feature-policy";
+
+const noDeletesSelected: AccountDeleteSettings = {
+ deletePosts: false,
+ deletePostsDaysOldEnabled: false,
+ deletePostsDaysOld: 0,
+ deletePostsLikesThresholdEnabled: false,
+ deletePostsLikesThreshold: 0,
+ deletePostsRepostsThresholdEnabled: false,
+ deletePostsRepostsThreshold: 0,
+ deletePostsPreserveThreads: false,
+ deleteReposts: false,
+ deleteRepostsDaysOldEnabled: false,
+ deleteRepostsDaysOld: 0,
+ deleteLikes: false,
+ deleteLikesDaysOldEnabled: false,
+ deleteLikesDaysOld: 0,
+ deleteBookmarks: false,
+ deleteChats: false,
+ deleteChatsDaysOldEnabled: false,
+ deleteChatsDaysOld: 0,
+ deleteUnfollowEveryone: false,
+};
+
+describe("premium feature policy", () => {
+ it("allows every save feature without Premium when no delete feature is selected", () => {
+ expect(
+ saveAndDeleteOptionsRequirePremium({
+ saveOptions: {
+ posts: true,
+ likes: true,
+ bookmarks: true,
+ chat: true,
+ },
+ deleteOptions: { settings: noDeletesSelected },
+ }),
+ ).toBe(false);
+ });
+
+ it.each([
+ "deletePosts",
+ "deleteReposts",
+ "deleteLikes",
+ "deleteBookmarks",
+ "deleteChats",
+ "deleteUnfollowEveryone",
+ ])("requires Premium when %s is selected", (feature) => {
+ expect(
+ deleteSettingsRequirePremium({
+ ...noDeletesSelected,
+ [feature]: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("ignores delete filters when their parent delete feature is not selected", () => {
+ expect(
+ deleteSettingsRequirePremium({
+ ...noDeletesSelected,
+ deletePostsDaysOldEnabled: true,
+ deletePostsLikesThresholdEnabled: true,
+ deletePostsRepostsThresholdEnabled: true,
+ deletePostsPreserveThreads: true,
+ deleteRepostsDaysOldEnabled: true,
+ deleteLikesDaysOldEnabled: true,
+ deleteChatsDaysOldEnabled: true,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/controllers/bluesky/premium-feature-policy.ts b/controllers/bluesky/premium-feature-policy.ts
new file mode 100644
index 0000000..f60188a
--- /dev/null
+++ b/controllers/bluesky/premium-feature-policy.ts
@@ -0,0 +1,21 @@
+import type { SaveAndDeleteJobOptions } from "@/controllers/bluesky/job-types";
+import type { AccountDeleteSettings } from "@/database/delete-settings";
+
+export function deleteSettingsRequirePremium(
+ settings: AccountDeleteSettings,
+): boolean {
+ return Boolean(
+ settings.deletePosts ||
+ settings.deleteReposts ||
+ settings.deleteLikes ||
+ settings.deleteBookmarks ||
+ settings.deleteChats ||
+ settings.deleteUnfollowEveryone
+ );
+}
+
+export function saveAndDeleteOptionsRequirePremium(
+ options: SaveAndDeleteJobOptions,
+): boolean {
+ return deleteSettingsRequirePremium(options.deleteOptions.settings);
+}
diff --git a/services/__tests__/cyd-api-client.test.ts b/services/__tests__/cyd-api-client.test.ts
index b82e820..634e953 100644
--- a/services/__tests__/cyd-api-client.test.ts
+++ b/services/__tests__/cyd-api-client.test.ts
@@ -94,6 +94,38 @@ describe("CydAPIClient", () => {
);
expect(premium).toEqual({ premium_access: true });
});
+
+ it("force-refreshes authentication instead of reusing a cached bearer token", async () => {
+ client.setCredentials("revoked@example.com", "revoked-device-token");
+ mockFetch
+ .mockResolvedValueOnce({
+ status: 200,
+ json: async () => ({
+ api_token: "cached-api-token",
+ device_uuid: "device-uuid",
+ email: "revoked@example.com",
+ }),
+ })
+ .mockResolvedValueOnce({ status: 200 })
+ .mockResolvedValueOnce({ status: 401 });
+
+ expect(await client.ping()).toBe(true);
+
+ expect(await client.refreshAuthentication()).toEqual({
+ error: true,
+ message: "Failed to get token with the server.",
+ status: 401,
+ });
+ expect(mockFetch).toHaveBeenLastCalledWith(
+ `${API_URL}/token`,
+ expect.objectContaining({
+ body: JSON.stringify({
+ email: "revoked@example.com",
+ device_token: "revoked-device-token",
+ }),
+ }),
+ );
+ });
});
describe("setUserEmail", () => {
@@ -359,6 +391,19 @@ describe("CydAPIClient", () => {
});
describe("getUserPremium", () => {
+ it("preserves the authentication status when the device token is rejected", async () => {
+ client.setCredentials("revoked@example.com", "revoked-device-token");
+ mockFetch.mockResolvedValue({ status: 401 });
+
+ const result = await client.getUserPremium();
+
+ expect(result).toEqual({
+ error: true,
+ message: "Failed to get a new API token.",
+ status: 401,
+ });
+ });
+
it("should return premium info on success", async () => {
const premiumResponse = {
premium_price_annual_cents: 3599,
diff --git a/services/cyd-api-client.ts b/services/cyd-api-client.ts
index 7b79dad..fb0089c 100644
--- a/services/cyd-api-client.ts
+++ b/services/cyd-api-client.ts
@@ -153,6 +153,7 @@ export default class CydAPIClient {
private deviceToken: string | null = null;
private apiToken: string | null = null;
private deviceUUID: string | null = null;
+ private lastAPITokenErrorStatus: number | undefined;
constructor(apiURL: string, dashURL: string) {
this.apiURL = apiURL;
@@ -277,6 +278,7 @@ export default class CydAPIClient {
private async getNewAPIToken(): Promise {
console.log("Getting a new API token");
+ this.lastAPITokenErrorStatus = undefined;
if (
typeof this.userEmail === "string" &&
this.userEmail !== "" &&
@@ -288,6 +290,7 @@ export default class CydAPIClient {
device_token: this.deviceToken,
});
if ("error" in getTokenResp) {
+ this.lastAPITokenErrorStatus = getTokenResp.status;
console.log("Failed to get a new API token", getTokenResp.message);
return false;
}
@@ -304,6 +307,22 @@ export default class CydAPIClient {
return await this.getNewAPIToken();
}
+ async refreshAuthentication(): Promise {
+ this.invalidateAuthentication();
+ if (!this.userEmail || !this.deviceToken) {
+ return this.returnError("Cyd account credentials are missing.", 401);
+ }
+
+ const response = await this.getToken({
+ email: this.userEmail,
+ device_token: this.deviceToken,
+ });
+ if ("error" in response) {
+ return response;
+ }
+ return true;
+ }
+
// Auth API (not authenticated)
async authenticate(
@@ -459,7 +478,10 @@ export default class CydAPIClient {
async getUserPremium(): Promise {
console.log("GET /user/premium");
if (!(await this.validateAPIToken())) {
- return this.returnError("Failed to get a new API token.");
+ return this.returnError(
+ "Failed to get a new API token.",
+ this.lastAPITokenErrorStatus,
+ );
}
try {
const response = await this.fetchAuthenticated(