+ {_(
+ '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;
+};