feat(settings): unified Cloud Sync chooser with Readest Cloud as a first-class provider (#4976)

The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).

- Activation helpers move to services (cloudSyncActivation.ts) and
  accept 'readest' (= no third-party provider active); the component
  module re-exports for existing imports.
- Row status lines come from a pure, fully-tested state matrix
  (cloudSyncStatus.ts) so every string is enumerated in one place:
  signed-out / loading / active / available for Readest Cloud;
  not connected / configured / paused / syncing / sync failed /
  book-file-uploads-off warning / active for third-party rows. The
  paused state renders on the affected third-party row (the plan sketch
  placed it on the Readest row; the provider that is paused is the
  third-party one).
- The Readest Cloud row opens an inline sub-page (SubPageHeader + the
  storage/translation Quota + a NavigationRow out to Account) instead of
  ejecting from the Settings dialog; signed-out taps go to login and the
  radio is unchecked and disabled, so an idle signed-out state never
  shows a checked radio while nothing syncs.
- Premium-gated builds keep the Readest Cloud row and show the upgrade
  prompt only in place of the third-party rows.
- Two-direction capability Tips in the WebDAV/Drive sub-pages spell out
  both what syncs only to the user's server and what still flows through
  the Readest account; the Upload Book Files description notes that
  Readest Cloud uploads pause while the provider is selected.
- Manage Sync book/progress/note rows swap their description to
  'Managed by {{provider}}...' via the existing locked-row pattern while
  a third-party provider is selected; the toggles stay interactive and
  persist since they govern the native channels after switch-back.
- The chooser rows form a radiogroup (native same-name radios provide
  arrow-key group movement) with an accessible group label.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-07 01:16:14 +09:00
committed by GitHub
parent e08622b416
commit 57868a138e
8 changed files with 375 additions and 100 deletions
@@ -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');
@@ -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');
});
});
@@ -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 (
<li key={category} className='flex items-center justify-between gap-4 px-4 py-3'>
<div className='flex flex-col gap-0.5'>
<span className='text-base-content text-sm font-medium'>{c.title}</span>
<span className='text-base-content/60 text-xs'>
{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}
</span>
</div>
<input
@@ -10,6 +10,7 @@ import {
RiDiscordLine,
RiSendPlaneLine,
RiCloudLine,
RiCloudFill,
RiGoogleLine,
} from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
@@ -33,11 +34,27 @@ import SendToReadestForm from './integrations/SendToReadestForm';
import WebDAVForm from './integrations/WebDAVForm';
import GoogleDriveForm from './integrations/GoogleDriveForm';
import { persistActiveCloudProvider } from './integrations/cloudSync';
import { getReadestCloudRowStatus, getThirdPartyRowStatus } from './integrations/cloudSyncStatus';
import {
getCloudSyncProvider,
resolveCloudSyncGate,
type CloudSyncProviderKind,
} from '@/services/sync/cloudSyncProvider';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
import SubPageHeader from './SubPageHeader';
import { SectionTitle, SettingLabel } from './primitives';
import Quota from '@/components/Quota';
import { NavigationRow, SectionTitle, SettingLabel, Tips } from './primitives';
type SubPage = 'kosync' | 'webdav' | 'gdrive' | 'readwise' | 'hardcover' | 'opds' | 'send' | null;
type SubPage =
| 'kosync'
| 'webdav'
| 'gdrive'
| 'readest-cloud'
| 'readwise'
| 'hardcover'
| 'opds'
| 'send'
| null;
/**
* Integrations panel — single point of discovery for external service config:
@@ -64,11 +81,13 @@ const IntegrationsPanel: React.FC = () => {
// 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<SubPage>(null);
@@ -151,6 +170,22 @@ const IntegrationsPanel: React.FC = () => {
onBack={() => setSubPage(null)}
/>
<WebDAVForm />
{settings.webdav?.enabled && (
<div className='mt-5'>
<Tips>
<li>
{_(
'While WebDAV is selected, books, progress, and annotations sync only to your server.',
)}
</li>
<li>
{_(
'App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.',
)}
</li>
</Tips>
</div>
)}
</div>
);
if (subPage === 'gdrive')
@@ -165,6 +200,45 @@ const IntegrationsPanel: React.FC = () => {
onBack={() => setSubPage(null)}
/>
<GoogleDriveForm />
{settings.googleDrive?.enabled && (
<div className='mt-5'>
<Tips>
<li>
{_(
'While Google Drive is selected, books, progress, and annotations sync only to your Drive.',
)}
</li>
<li>
{_(
'App settings, reading statistics, and dictionaries still sync through your Readest account while signed in.',
)}
</li>
</Tips>
</div>
)}
</div>
);
if (subPage === 'readest-cloud')
return (
<div className='my-4 w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('Readest Cloud')}
description={_('Sync your library, reading progress, and highlights with Readest Cloud.')}
onBack={() => setSubPage(null)}
/>
<div className='space-y-5'>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border px-4 py-3'>
<Quota quotas={quotas} labelClassName='h-10' />
</div>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<NavigationRow
title={_('Account and Storage')}
status={_('Manage your plan and stored files')}
onClick={() => navigateToProfile(router)}
/>
</div>
</div>
</div>
);
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 = () => {
</div>
<div className='w-full' data-setting-id='settings.integrations.cloudSync'>
<SectionTitle className='mb-2'>{_('Third-party Cloud Sync')}</SectionTitle>
<SectionTitle className='mb-2'>{_('Cloud Sync')}</SectionTitle>
<p className='text-base-content/70 mb-2 px-1 text-sm leading-relaxed'>
{_(
'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.',
)}
</p>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<div
className='divide-base-200 divide-y'
role='radiogroup'
aria-label={_('Cloud sync provider')}
>
<CloudProviderRow
icon={RiCloudFill}
title={_('Readest Cloud')}
status={readestStatus}
isActive={!!user && cloudProvider === 'readest'}
canActivate={!!user}
onActivate={() => activateCloudProvider('readest')}
onOpen={() => (user ? setSubPage('readest-cloud') : navigateToLogin(router))}
activateLabel={_('Use Readest Cloud')}
/>
{isCloudSyncPremium ? (
<>
<CloudProviderRow
@@ -308,11 +408,11 @@ const IntegrationsPanel: React.FC = () => {
)}
</>
) : (
// 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.
<IntegrationRow
icon={RiCloudLine}
title={_('Cloud Sync')}
title={_('Third-party Cloud Sync')}
status={_('Available on Plus, Pro, or Lifetime')}
onClick={() => navigateToProfile(router)}
/>
@@ -186,7 +186,9 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({
<BoxedList>
<SettingsSwitchRow
label={_('Upload Book Files')}
description={_('Uploads book files to your other devices.')}
description={_(
'Uploads book files to your other devices. Readest Cloud uploads pause while this provider is selected.',
)}
checked={stored.syncBooks ?? false}
onChange={handleToggleSyncBooks}
/>
@@ -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<SystemSettings> => {
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';
@@ -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');
};
@@ -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<SystemSettings> => {
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;
};