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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user