diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts index 827396d3..4a3142b3 100644 --- a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts +++ b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts @@ -29,6 +29,15 @@ describe('withActiveCloudProvider', () => { expect(next.googleDrive.enabled).toBe(false); }); + test("'readest' behaves as deactivation of both third-party providers", () => { + const next = withActiveCloudProvider(base, 'readest'); + expect(next.webdav.enabled).toBe(false); + expect(next.googleDrive.enabled).toBe(false); + // Config survives so switching back needs no re-entry. + expect(next.webdav.serverUrl).toBe('https://dav'); + expect(next.googleDrive.accountLabel).toBe('a@b.com'); + }); + test('leaves the rest of each provider config untouched', () => { const next = withActiveCloudProvider(base, 'gdrive'); expect(next.webdav.serverUrl).toBe('https://dav'); diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts new file mode 100644 index 00000000..bb56084c --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/cloudSyncStatus.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'vitest'; +import { + getReadestCloudRowStatus, + getThirdPartyRowStatus, +} from '@/components/settings/integrations/cloudSyncStatus'; + +const _ = (key: string) => key; + +describe('getReadestCloudRowStatus', () => { + test('signed out wins over everything', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: false, planLoading: true, selected: true }), + ).toBe('Not signed in'); + }); + + test('loading while the plan resolves', () => { + expect(getReadestCloudRowStatus(_, { signedIn: true, planLoading: true, selected: true })).toBe( + '…', + ); + }); + + test('active when signed in and selected', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: true, planLoading: false, selected: true }), + ).toBe('Active — syncing your library on this device'); + }); + + test('available when a third-party provider is selected instead', () => { + expect( + getReadestCloudRowStatus(_, { signedIn: true, planLoading: false, selected: false }), + ).toBe('Available'); + }); +}); + +describe('getThirdPartyRowStatus', () => { + const base = { + enabled: true, + configured: true, + syncing: false, + paused: false, + lastError: null, + syncBooks: true, + }; + + test('not connected / configured when inactive', () => { + expect(getThirdPartyRowStatus(_, { ...base, enabled: false, configured: false })).toBe( + 'Not connected', + ); + expect(getThirdPartyRowStatus(_, { ...base, enabled: false })).toBe('Configured'); + }); + + test('paused outranks syncing, errors, and warnings', () => { + expect( + getThirdPartyRowStatus(_, { ...base, paused: true, syncing: true, lastError: 'x' }), + ).toBe('Paused — plan required'); + }); + + test('syncing while a run is in flight', () => { + expect(getThirdPartyRowStatus(_, { ...base, syncing: true })).toBe('Syncing…'); + }); + + test('sync failed after a terminal error', () => { + expect(getThirdPartyRowStatus(_, { ...base, lastError: 'AUTH_FAILED' })).toBe('Sync failed'); + }); + + test('warns when book file uploads are off (books back up nowhere)', () => { + expect(getThirdPartyRowStatus(_, { ...base, syncBooks: false })).toBe( + 'Active · Book file uploads off', + ); + }); + + test('healthy active state', () => { + expect(getThirdPartyRowStatus(_, base)).toBe('Active — syncing your library on this device'); + }); +}); diff --git a/apps/readest-app/src/app/user/components/SyncCategoriesSection.tsx b/apps/readest-app/src/app/user/components/SyncCategoriesSection.tsx index e0751a5a..34c46283 100644 --- a/apps/readest-app/src/app/user/components/SyncCategoriesSection.tsx +++ b/apps/readest-app/src/app/user/components/SyncCategoriesSection.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; +import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider'; import { SYNC_CATEGORIES, isSyncCategoryLocked, @@ -71,6 +72,8 @@ export function SyncCategoriesSection() { const { envConfig } = useEnv(); const { settings, setSettings, saveSettings } = useSettingsStore(); const copy = useCategoryCopy(); + const cloudProvider = getCloudSyncProvider(settings); + const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV'; if (!settings) return null; @@ -110,12 +113,27 @@ export function SyncCategoriesSection() { const c = copy[category]; const on = enabled(category); const locked = isSyncCategoryLocked(category); + // While a third-party cloud provider is selected, the book / + // progress / note channels are routed to it at runtime and these + // toggles have no immediate effect. The description says so in + // place (same pattern as `locked`), but the toggle stays + // interactive and persists: it governs the native channel the + // user returns to when Readest Cloud is re-selected. + const managedByProvider = + cloudProvider !== 'readest' && + (category === 'book' || category === 'progress' || category === 'note'); return (
  • {c.title} - {locked ? _('Required while Dictionaries sync is enabled') : c.description} + {managedByProvider + ? _('Managed by {{provider}} while it is your cloud sync provider', { + provider: cloudProviderName, + }) + : locked + ? _('Required while Dictionaries sync is enabled') + : c.description}
    { // when they back out of the WebDAV sub-page or close the dialog. const isWebDAVSyncing = useFileSyncStore((s) => s.byKind.webdav?.isSyncing ?? false); const isGDriveSyncing = useFileSyncStore((s) => s.byKind.gdrive?.isSyncing ?? false); + const webdavLastError = useFileSyncStore((s) => s.lastErrorByKind.webdav); + const gdriveLastError = useFileSyncStore((s) => s.lastErrorByKind.gdrive); // Third-party cloud sync will be a premium feature (any paid plan), but it is // temporarily UNGATED while the feature stabilises — `isCloudSyncAllowed` // returns true for every plan until `CLOUD_SYNC_REQUIRES_PREMIUM` is flipped // back on. The `?? 'free'` keeps the (re-gated) loading state non-premium. - const { userProfilePlan } = useQuotaStats(); + const { userProfilePlan, quotas } = useQuotaStats(); const isCloudSyncPremium = isCloudSyncAllowed(userProfilePlan ?? 'free'); const [subPage, setSubPage] = useState(null); @@ -151,6 +170,22 @@ const IntegrationsPanel: React.FC = () => { onBack={() => setSubPage(null)} /> + {settings.webdav?.enabled && ( +
    + +
  • + {_( + 'While WebDAV is selected, books, progress, and annotations sync only to your server.', + )} +
  • +
  • + {_( + 'App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.', + )} +
  • + + + )} ); if (subPage === 'gdrive') @@ -165,6 +200,45 @@ const IntegrationsPanel: React.FC = () => { onBack={() => setSubPage(null)} /> + {settings.googleDrive?.enabled && ( +
    + +
  • + {_( + 'While Google Drive is selected, books, progress, and annotations sync only to your Drive.', + )} +
  • +
  • + {_( + 'App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.', + )} +
  • +
    +
    + )} + + ); + if (subPage === 'readest-cloud') + return ( +
    + setSubPage(null)} + /> +
    +
    + +
    +
    + navigateToProfile(router)} + /> +
    +
    ); if (subPage === 'readwise') @@ -207,32 +281,39 @@ const IntegrationsPanel: React.FC = () => { const readwiseStatus = settings.readwise?.enabled ? _('Connected') : _('Not connected'); const hardcoverStatus = settings.hardcover?.enabled ? _('Connected') : _('Not connected'); - // Third-party cloud providers are mutually exclusive: at most one is the - // active sync target. A "configured" provider (WebDAV creds / a Drive token) - // can be switched on inline; an unconfigured one must be opened to connect. - const activeCloudKind: FileSyncBackendKind | null = settings.webdav?.enabled - ? 'webdav' - : settings.googleDrive?.enabled - ? 'gdrive' - : null; + // Cloud sync providers are mutually exclusive: exactly one of + // {Readest Cloud, WebDAV, Google Drive} owns library sync. A "configured" + // third-party provider (WebDAV creds / a Drive token) can be switched on + // inline; an unconfigured one must be opened to connect. + const cloudProvider = getCloudSyncProvider(settings); + const activeCloudKind: FileSyncBackendKind | null = + cloudProvider === 'readest' ? null : cloudProvider; + const cloudGate = resolveCloudSyncGate(settings, userProfilePlan ?? 'free'); const webdavConfigured = !!(settings.webdav?.serverUrl && settings.webdav?.username); const gdriveConfigured = !!settings.googleDrive?.accountLabel; - const webdavStatus = settings.webdav?.enabled - ? isWebDAVSyncing - ? _('Syncing…') - : _('Active') - : webdavConfigured - ? _('Configured') - : _('Not connected'); - const gdriveStatus = settings.googleDrive?.enabled - ? isGDriveSyncing - ? _('Syncing…') - : _('Active') - : gdriveConfigured - ? _('Configured') - : _('Not connected'); + const webdavStatus = getThirdPartyRowStatus(_, { + enabled: !!settings.webdav?.enabled, + configured: webdavConfigured, + syncing: isWebDAVSyncing, + paused: cloudGate.paused && cloudProvider === 'webdav', + lastError: webdavLastError, + syncBooks: settings.webdav?.syncBooks ?? false, + }); + const gdriveStatus = getThirdPartyRowStatus(_, { + enabled: !!settings.googleDrive?.enabled, + configured: gdriveConfigured, + syncing: isGDriveSyncing, + paused: cloudGate.paused && cloudProvider === 'gdrive', + lastError: gdriveLastError, + syncBooks: settings.googleDrive?.syncBooks ?? false, + }); + const readestStatus = getReadestCloudRowStatus(_, { + signedIn: !!user, + planLoading: userProfilePlan === undefined, + selected: cloudProvider === 'readest', + }); - const activateCloudProvider = async (kind: FileSyncBackendKind) => { + const activateCloudProvider = async (kind: CloudSyncProviderKind) => { await persistActiveCloudProvider(envConfig, kind); }; @@ -275,9 +356,28 @@ const IntegrationsPanel: React.FC = () => {
    - {_('Third-party Cloud Sync')} + {_('Cloud Sync')} +

    + {_( + 'Choose where your library — books, progress, annotations — syncs on this device. App settings, reading statistics, and dictionaries always sync with your Readest account while signed in.', + )} +

    -
    +
    + activateCloudProvider('readest')} + onOpen={() => (user ? setSubPage('readest-cloud') : navigateToLogin(router))} + activateLabel={_('Use Readest Cloud')} + /> {isCloudSyncPremium ? ( <> { )} ) : ( - // Premium-gated: free users get an upgrade prompt instead of the - // provider rows. Tapping opens the plans page. + // Premium-gated: free users keep the Readest Cloud row above and + // get an upgrade prompt in place of the third-party rows. navigateToProfile(router)} /> diff --git a/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx index 2c3897f3..2e9f4221 100644 --- a/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx +++ b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx @@ -186,7 +186,9 @@ const FileSyncForm: React.FC = ({ diff --git a/apps/readest-app/src/components/settings/integrations/cloudSync.ts b/apps/readest-app/src/components/settings/integrations/cloudSync.ts index 3b7b60dc..0603aa3f 100644 --- a/apps/readest-app/src/components/settings/integrations/cloudSync.ts +++ b/apps/readest-app/src/components/settings/integrations/cloudSync.ts @@ -1,69 +1,10 @@ -import type { SystemSettings } from '@/types/settings'; -import type { EnvConfigType } from '@/services/environment'; -import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry'; -import { useSettingsStore } from '@/store/settingsStore'; -import { broadcastGlobalSettings } from '@/utils/settingsSync'; - /** - * Return settings with exactly one third-party cloud-sync provider active (or - * none). WebDAV and Google Drive are mutually exclusive — only one syncs the - * library at a time — so enabling one always disables the other. Provider config - * (WebDAV creds, the Drive keychain token) is left untouched, so switching back - * to a previously-configured provider needs no re-entry; only an explicit - * Disconnect tears a provider's config down. - * - * Activating a provider (disabled -> enabled) also turns its `syncBooks` on: - * the selected provider owns the book-file channel — native Readest Cloud - * uploads gate off — so leaving syncBooks at its `false` default would back - * books up nowhere. An explicit opt-out while the provider stays active is - * respected (re-activation of an already-active provider changes nothing). + * Re-export shim: the activation helpers moved to the services layer + * (`src/services/sync/cloudSyncActivation.ts`) so service modules never + * import from components. Existing component-side imports keep working. */ -export const withActiveCloudProvider = ( - settings: SystemSettings, - active: FileSyncBackendKind | null, -): SystemSettings => ({ - ...settings, - webdav: { - ...settings.webdav, - enabled: active === 'webdav', - ...(active === 'webdav' && !settings.webdav?.enabled - ? { syncBooks: true, providerSelectedAt: Date.now() } - : {}), - }, - googleDrive: { - ...settings.googleDrive, - enabled: active === 'gdrive', - ...(active === 'gdrive' && !settings.googleDrive?.enabled - ? { syncBooks: true, providerSelectedAt: Date.now() } - : {}), - }, -}); - -/** - * The single write path for changing the selected cloud sync provider. - * Every activation/deactivation surface (the Integrations chooser, the - * WebDAV/Drive connect and disconnect flows, the Drive OAuth callback) - * routes through here so the change always (a) persists, (b) hydrates the - * settings store even on routes where it wasn't loaded yet (the OAuth - * callback), and (c) broadcasts the provider flags to other windows — - * a stale reader window would otherwise clobber the switch on its next - * whole-file save, silently resuming Readest Cloud uploads. - * - * `mutate` runs BEFORE activation so connect flows can apply credentials - * or account labels without pre-setting `enabled` (which would suppress - * the activation side effects). - */ -export const persistActiveCloudProvider = async ( - envConfig: EnvConfigType, - active: FileSyncBackendKind | null, - mutate: (settings: SystemSettings) => SystemSettings = (s) => s, -): Promise => { - const store = useSettingsStore.getState(); - const appService = await envConfig.getAppService(); - const current = store.settings?.version ? store.settings : await appService.loadSettings(); - const next = withActiveCloudProvider(mutate(current), active); - store.setSettings(next); - await appService.saveSettings(next); - void broadcastGlobalSettings(next, { includeCloudSyncProviders: true }); - return next; -}; +export { + withActiveCloudProvider, + persistActiveCloudProvider, + type CloudSyncActivationKind, +} from '@/services/sync/cloudSyncActivation'; diff --git a/apps/readest-app/src/components/settings/integrations/cloudSyncStatus.ts b/apps/readest-app/src/components/settings/integrations/cloudSyncStatus.ts new file mode 100644 index 00000000..33629e26 --- /dev/null +++ b/apps/readest-app/src/components/settings/integrations/cloudSyncStatus.ts @@ -0,0 +1,48 @@ +import type { TranslationFunc } from '@/hooks/useTranslation'; + +/** + * Status-line derivation for the Cloud Sync chooser rows. Pure functions so + * the full row-state matrix is unit-tested and every user-visible string is + * enumerated here (one place for the /i18n extraction), never improvised at + * the call site. + */ + +export interface ReadestRowInputs { + signedIn: boolean; + /** Plan still resolving from the JWT (signed-in only). */ + planLoading: boolean; + /** Readest Cloud is the derived provider. */ + selected: boolean; +} + +export const getReadestCloudRowStatus = (_: TranslationFunc, s: ReadestRowInputs): string => { + if (!s.signedIn) return _('Not signed in'); + if (s.planLoading) return '…'; + if (s.selected) return _('Active — syncing your library on this device'); + return _('Available'); +}; + +export interface ThirdPartyRowInputs { + enabled: boolean; + configured: boolean; + syncing: boolean; + /** Selected but disallowed by the premium guard (never silently unpaused). */ + paused: boolean; + /** Last terminal sync error, from fileSyncStore. */ + lastError: string | null | undefined; + /** The provider's Upload Book Files toggle. */ + syncBooks: boolean; +} + +export const getThirdPartyRowStatus = (_: TranslationFunc, s: ThirdPartyRowInputs): string => { + if (!s.enabled) return s.configured ? _('Configured') : _('Not connected'); + if (s.paused) return _('Paused — plan required'); + if (s.syncing) return _('Syncing…'); + if (s.lastError) return _('Sync failed'); + if (!s.syncBooks) { + // Books back up NOWHERE in this state (native uploads are gated and the + // provider is opted out of book files) — the row must say so. + return _('Active · Book file uploads off'); + } + return _('Active — syncing your library on this device'); +}; diff --git a/apps/readest-app/src/services/sync/cloudSyncActivation.ts b/apps/readest-app/src/services/sync/cloudSyncActivation.ts new file mode 100644 index 00000000..db591b1f --- /dev/null +++ b/apps/readest-app/src/services/sync/cloudSyncActivation.ts @@ -0,0 +1,82 @@ +import type { SystemSettings } from '@/types/settings'; +import type { EnvConfigType } from '@/services/environment'; +import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry'; +import type { CloudSyncProviderKind } from '@/services/sync/cloudSyncProvider'; +import { useSettingsStore } from '@/store/settingsStore'; +import { broadcastGlobalSettings } from '@/utils/settingsSync'; + +/** + * The provider argument for activation: a third-party backend, or + * 'readest' / null (both mean "no third-party provider active" — Readest + * Cloud is the derived default, never a stored flag). + */ +export type CloudSyncActivationKind = CloudSyncProviderKind | null; + +const isThirdParty = (active: CloudSyncActivationKind): active is FileSyncBackendKind => + active === 'webdav' || active === 'gdrive'; + +/** + * Return settings with exactly one third-party cloud-sync provider active (or + * none — passing 'readest' or null). WebDAV and Google Drive are mutually + * exclusive — only one syncs the library at a time — so enabling one always + * disables the other. Provider config (WebDAV creds, the Drive keychain + * token) is left untouched, so switching back to a previously-configured + * provider needs no re-entry; only an explicit Disconnect tears a provider's + * config down. + * + * Activating a provider (disabled -> enabled) also turns its `syncBooks` on + * and stamps `providerSelectedAt`: the selected provider owns the book-file + * channel — native Readest Cloud uploads gate off — so leaving syncBooks at + * its `false` default would back books up nowhere, and the timestamp anchors + * the mixed-fleet detection probe. An explicit syncBooks opt-out while the + * provider stays active is respected (re-activation changes nothing). + */ +export const withActiveCloudProvider = ( + settings: SystemSettings, + active: CloudSyncActivationKind, +): SystemSettings => ({ + ...settings, + webdav: { + ...settings.webdav, + enabled: active === 'webdav', + ...(active === 'webdav' && !settings.webdav?.enabled + ? { syncBooks: true, providerSelectedAt: Date.now() } + : {}), + }, + googleDrive: { + ...settings.googleDrive, + enabled: active === 'gdrive', + ...(active === 'gdrive' && !settings.googleDrive?.enabled + ? { syncBooks: true, providerSelectedAt: Date.now() } + : {}), + }, +}); + +/** + * The single write path for changing the selected cloud sync provider. + * Every activation/deactivation surface (the Cloud Sync chooser, the + * WebDAV/Drive connect and disconnect flows, the Drive OAuth callback) + * routes through here so the change always (a) persists, (b) hydrates the + * settings store even on routes where it wasn't loaded yet (the OAuth + * callback), and (c) broadcasts the provider flags to other windows — + * a stale reader window would otherwise clobber the switch on its next + * whole-file save, silently resuming Readest Cloud uploads. + * + * `mutate` runs BEFORE activation so connect flows can apply credentials + * or account labels without pre-setting `enabled` (which would suppress + * the activation side effects). + */ +export const persistActiveCloudProvider = async ( + envConfig: EnvConfigType, + active: CloudSyncActivationKind, + mutate: (settings: SystemSettings) => SystemSettings = (s) => s, +): Promise => { + const store = useSettingsStore.getState(); + const appService = await envConfig.getAppService(); + const current = store.settings?.version ? store.settings : await appService.loadSettings(); + const next = withActiveCloudProvider(mutate(current), isThirdParty(active) ? active : null); + store.setSettings(next); + await appService.saveSettings(next); + void broadcastGlobalSettings(next, { includeCloudSyncProviders: true }); + return next; +};