Files
readest/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
T
Huang Xin e08622b416 feat(sync): route library sync exclusively to the selected cloud provider (#4380) (#4975)
While WebDAV or Google Drive is the selected cloud sync backend, the
native Readest Cloud book/progress/note channels are gated off and the
file-sync engine owns library data end to end:

- isSyncCategoryEnabled returns false for book/progress/note (and their
  legacy aliases) when a third-party provider is selected. A runtime
  override, deliberately not written into syncCategories: the user's own
  toggles persist and take effect again on switch-back. Account channels
  (settings, stats, dictionaries, fonts, textures, OPDS catalogs) always
  stay native.
- persistActiveCloudProvider is the single write path for provider
  switching, used by the chooser, both connect/disconnect flows, and the
  Drive OAuth callback (which previously bypassed the cross-window
  broadcast). The broadcast carries ONLY the enabled flags plus
  providerSelectedAt, never credentials or sync cursors, and only on
  switch events, so a stale window's routine save cannot revert a switch.
- buildWebDAVConnectSettings no longer pre-sets enabled: activation
  belongs to withActiveCloudProvider, so the fresh-connect path now gets
  the syncBooks auto-flip and the providerSelectedAt stamp.
- Sync health: fileSyncStore records lastError per backend; the durable
  lastSyncedAt stays in provider settings. The SettingsMenu sync row
  reads Synced via provider / Sync failed and its tap (with pull to
  refresh and BackupWindow, all routed through pullLibrary) runs the
  file engine via the shared runActiveFileLibrarySync helper instead of
  a gated native pull that would toast undefined book(s) synced.
- Mixed-fleet detection: while gated, the auto-sync interval runs a
  read-only probe of /api/sync since providerSelectedAt; any newer book
  row means another device still syncs natively, and a once-per-session
  notice explains the fork instead of leaving it silent.
- Readest-Cloud-only affordances hide while a third-party provider is
  selected: the quota row becomes a caption naming the active provider,
  Auto Upload disappears from the menu and command palette, the
  BookItem upload badge and the Transfer Queue Upload All button hide.
- providerSelectedAt added to both provider settings types and the
  backup blacklist.

Stacked on the quota-decoupling change for #4959; requires the
metadata-parity change so gated channels lose nothing users can see.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:05:54 +02:00

526 lines
19 KiB
TypeScript

import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { MdChevronRight } from 'react-icons/md';
import {
RiBookOpenLine,
RiRssLine,
RiBookReadLine,
RiBook3Line,
RiDiscordLine,
RiSendPlaneLine,
RiCloudLine,
RiGoogleLine,
} from 'react-icons/ri';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { useFileSyncStore } from '@/store/fileSyncStore';
import { CatalogManager } from '@/app/opds/components/CatalogManager';
import { saveSysSettings } from '@/helpers/settings';
import { isCloudSyncAllowed } from '@/utils/access';
import { isWebAppPlatform } from '@/services/environment';
import { getGoogleWebClientId } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import KOSyncForm from './integrations/KOSyncForm';
import ReadwiseForm from './integrations/ReadwiseForm';
import HardcoverForm from './integrations/HardcoverForm';
import SendToReadestForm from './integrations/SendToReadestForm';
import WebDAVForm from './integrations/WebDAVForm';
import GoogleDriveForm from './integrations/GoogleDriveForm';
import { persistActiveCloudProvider } from './integrations/cloudSync';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
import SubPageHeader from './SubPageHeader';
import { SectionTitle, SettingLabel } from './primitives';
type SubPage = 'kosync' | 'webdav' | 'gdrive' | 'readwise' | 'hardcover' | 'opds' | 'send' | null;
/**
* Integrations panel — single point of discovery for external service config:
* KOReader Sync, Readwise, Hardcover, and OPDS Catalogs.
*
* Pattern: boxed list of NavigationRows. Each row pushes the panel into an
* inline sub-page (with breadcrumb back-navigation matching the Dictionaries
* pattern) — no nested modals.
*
* TODO(design-system): Once we extract BoxedList / NavigationRow primitives,
* this panel and CustomDictionaries should both consume them instead of
* inlining the chassis.
*/
const IntegrationsPanel: React.FC = () => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { settings, requestedSubPage, setRequestedSubPage } = useSettingsStore();
const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs);
const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length;
// Surface a library-wide WebDAV sync that's mid-flight in the row's
// status line. Keeps the user from feeling like the run was lost
// 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);
// 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 isCloudSyncPremium = isCloudSyncAllowed(userProfilePlan ?? 'free');
const [subPage, setSubPage] = useState<SubPage>(null);
// Android Back / Esc: when any integrations sub-page (KOSync, WebDAV,
// Readwise, Hardcover, OPDS, Send-to-Readest) is open, intercept and
// step back to the integrations list instead of letting <Dialog>'s
// listener close the whole Settings dialog. The hook registers its
// sync `native-key-down` listener *after* <Dialog>'s, and
// `dispatchSync` walks listeners LIFO — so this one claims Back first
// when enabled and `return true` consumes the event. When subPage is
// null the hook is disabled and Back falls through to close the dialog
// as before.
useKeyDownActions({
enabled: subPage !== null,
onCancel: () => setSubPage(null),
});
const toggleDiscordPresence = () => {
const discordRichPresenceEnabled = !settings.discordRichPresenceEnabled;
saveSysSettings(envConfig, 'discordRichPresenceEnabled', discordRichPresenceEnabled);
if (discordRichPresenceEnabled && !user) {
navigateToLogin(router);
}
};
// Deep-link consumption: when a caller (e.g. OPDS browser close handler)
// sets `requestedSubPage` in the store before opening the dialog, drill
// straight into that sub-page on mount and clear the request so it doesn't
// stick to the next open. Recognised values match the SubPage union.
useEffect(() => {
if (!requestedSubPage) return;
const isCloudRequest =
requestedSubPage === 'webdav' ||
requestedSubPage === 'gdrive' ||
requestedSubPage === 'cloudsync';
// Cloud-sync sub-pages are premium-gated. If the plan is still loading, wait
// (don't consume the request); once known, only honor it for paid plans.
if (isCloudRequest && !isCloudSyncPremium) {
if (userProfilePlan === undefined) return;
setRequestedSubPage(null);
return;
}
if (
requestedSubPage === 'kosync' ||
requestedSubPage === 'webdav' ||
requestedSubPage === 'gdrive' ||
requestedSubPage === 'readwise' ||
requestedSubPage === 'hardcover' ||
requestedSubPage === 'opds' ||
requestedSubPage === 'send'
) {
setSubPage(requestedSubPage);
} else if (requestedSubPage === 'cloudsync') {
// Back-compat with the brief unified "Cloud Sync" page.
setSubPage('gdrive');
}
setRequestedSubPage(null);
}, [requestedSubPage, setRequestedSubPage, isCloudSyncPremium, userProfilePlan]);
// Sub-page wrapper matches the list-view's `my-4 w-full` so the
// SubPageHeader's "Integrations" label lands at the exact same Y position
// as the list-view's h2 — clicking a row reads as a navigation morph
// rather than a layout shift.
if (subPage === 'kosync')
return (
<div className='my-4 w-full'>
<KOSyncForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'webdav')
return (
<div className='my-4 w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('WebDAV')}
description={_(
'Sync your library, reading progress, and highlights with a WebDAV server.',
)}
onBack={() => setSubPage(null)}
/>
<WebDAVForm />
</div>
);
if (subPage === 'gdrive')
return (
<div className='my-4 w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('Google Drive')}
description={_(
'Sync your library, reading progress, and highlights with your Google Drive.',
)}
onBack={() => setSubPage(null)}
/>
<GoogleDriveForm />
</div>
);
if (subPage === 'readwise')
return (
<div className='my-4 w-full'>
<ReadwiseForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'hardcover')
return (
<div className='my-4 w-full'>
<HardcoverForm onBack={() => setSubPage(null)} />
</div>
);
if (subPage === 'opds')
return (
<div className='my-4 w-full'>
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('OPDS Catalogs')}
description={_('Browse and download books from online catalogs.')}
onBack={() => setSubPage(null)}
/>
<CatalogManager inSubPage />
</div>
);
if (subPage === 'send')
return (
<div className='my-4 w-full'>
<SendToReadestForm onBack={() => setSubPage(null)} />
</div>
);
const koSyncStatus = settings.kosync?.enabled
? settings.kosync.username
? _('Connected as {{user}}', { user: settings.kosync.username })
: _('Connected')
: _('Not connected');
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;
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 activateCloudProvider = async (kind: FileSyncBackendKind) => {
await persistActiveCloudProvider(envConfig, kind);
};
const opdsStatus =
opdsCount > 0 ? _('{{count}} catalog', { count: opdsCount }) : _('No catalogs');
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full px-4'>
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
<p className='text-base-content/70 text-sm leading-relaxed'>
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
</p>
</div>
<div className='w-full' data-setting-id='settings.integrations.sync'>
<SectionTitle className='mb-2'>{_('Reading Sync')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<IntegrationRow
icon={RiBookOpenLine}
title={_('KOReader Sync')}
status={koSyncStatus}
onClick={() => setSubPage('kosync')}
/>
<IntegrationRow
icon={RiBookReadLine}
title={_('Readwise')}
status={readwiseStatus}
onClick={() => setSubPage('readwise')}
/>
<IntegrationRow
icon={RiBook3Line}
title={_('Hardcover')}
status={hardcoverStatus}
onClick={() => setSubPage('hardcover')}
/>
</div>
</div>
</div>
<div className='w-full' data-setting-id='settings.integrations.cloudSync'>
<SectionTitle className='mb-2'>{_('Third-party Cloud Sync')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
{isCloudSyncPremium ? (
<>
<CloudProviderRow
icon={RiCloudLine}
title={_('WebDAV')}
status={webdavStatus}
isActive={activeCloudKind === 'webdav'}
canActivate={webdavConfigured}
onActivate={() => activateCloudProvider('webdav')}
onOpen={() => setSubPage('webdav')}
activateLabel={_('Use WebDAV')}
/>
{(appService?.isDesktopApp ||
appService?.isAndroidApp ||
appService?.isIOSApp ||
// Web: only when a Web-type GIS client id is configured for this build.
(isWebAppPlatform() && !!getGoogleWebClientId())) && (
<CloudProviderRow
icon={RiGoogleLine}
title={_('Google Drive')}
status={gdriveStatus}
isActive={activeCloudKind === 'gdrive'}
canActivate={gdriveConfigured}
onActivate={() => activateCloudProvider('gdrive')}
onOpen={() => setSubPage('gdrive')}
activateLabel={_('Use Google Drive')}
/>
)}
</>
) : (
// Premium-gated: free users get an upgrade prompt instead of the
// provider rows. Tapping opens the plans page.
<IntegrationRow
icon={RiCloudLine}
title={_('Cloud Sync')}
status={_('Available on Plus, Pro, or Lifetime')}
onClick={() => navigateToProfile(router)}
/>
)}
</div>
</div>
</div>
<div className='w-full' data-setting-id='settings.integrations.catalogs'>
<SectionTitle className='mb-2'>{_('Content Sources')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<IntegrationRow
icon={RiRssLine}
title={_('OPDS Catalogs')}
status={opdsStatus}
onClick={() => setSubPage('opds')}
/>
<IntegrationRow
icon={RiSendPlaneLine}
title={_('Send to Readest')}
status={_('Email books to your library')}
onClick={() => setSubPage('send')}
/>
</div>
</div>
</div>
{appService?.isDesktopApp && (
<div className='w-full' data-setting-id='settings.integrations.discord'>
<SectionTitle className='mb-2'>{_('Discord')}</SectionTitle>
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='divide-base-200 divide-y'>
<IntegrationToggleRow
icon={RiDiscordLine}
title={_('Show on Discord')}
description={_("Display what I'm reading on Discord")}
checked={settings.discordRichPresenceEnabled}
onChange={toggleDiscordPresence}
/>
</div>
</div>
</div>
)}
</div>
);
};
interface IntegrationRowProps {
icon: React.ElementType;
title: string;
status: string;
onClick: () => void;
}
const IntegrationRow: React.FC<IntegrationRowProps> = ({ icon: Icon, title, status, onClick }) => {
return (
<button
type='button'
onClick={onClick}
className={clsx(
'group flex w-full items-center gap-3 px-4 py-3 text-left',
'transition-colors duration-150',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset',
)}
>
<span
className={clsx(
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full',
'bg-base-200 text-base-content/70',
'transition-colors duration-150',
'group-hover:bg-base-300/70',
)}
>
<Icon className='h-5 w-5' />
</span>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<SettingLabel>{title}</SettingLabel>
<span className='text-base-content/65 truncate text-[0.85em]'>{status}</span>
</div>
<MdChevronRight className='text-base-content/50 h-5 w-5 flex-shrink-0' />
</button>
);
};
interface CloudProviderRowProps {
icon: React.ElementType;
title: string;
status: string;
/** This provider is the active sync target. */
isActive: boolean;
/** Configured (credentials / token present) — can be switched on inline. */
canActivate: boolean;
onActivate: () => void;
onOpen: () => void;
/** Accessible label for the activate radio (e.g. "Use WebDAV"). */
activateLabel: string;
}
/**
* A third-party cloud-sync provider row. Two controls: a trailing radio that
* makes this provider the (single) active one inline — enabled only when it's
* already configured — and the row body / chevron that opens its config
* sub-page (connect, sync options, disconnect).
*/
const CloudProviderRow: React.FC<CloudProviderRowProps> = ({
icon: Icon,
title,
status,
isActive,
canActivate,
onActivate,
onOpen,
activateLabel,
}) => {
return (
<div className='group flex w-full items-center gap-3 px-4 py-3'>
<button
type='button'
onClick={onOpen}
className={clsx(
'flex min-w-0 flex-1 items-center gap-3 text-left',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset',
)}
>
<span
className={clsx(
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full',
'bg-base-200 text-base-content/70',
'transition-colors duration-150',
'group-hover:bg-base-300/70',
)}
>
<Icon className='h-5 w-5' />
</span>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<SettingLabel>{title}</SettingLabel>
<span className='text-base-content/65 truncate text-[0.85em]'>{status}</span>
</div>
</button>
<input
type='radio'
name='cloud-sync-active'
className='radio radio-sm flex-shrink-0'
checked={isActive}
disabled={!canActivate}
onChange={onActivate}
aria-label={activateLabel}
title={activateLabel}
/>
<button
type='button'
onClick={onOpen}
aria-label={title}
className={clsx(
'text-base-content/50 hover:text-base-content/80 flex-shrink-0 rounded',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<MdChevronRight className='h-5 w-5' />
</button>
</div>
);
};
interface IntegrationToggleRowProps {
icon: React.ElementType;
title: string;
description: string;
checked: boolean;
onChange: () => void;
}
/**
* Sibling of IntegrationRow for settings that are a simple on/off toggle
* (no sub-page). Keeps the same circular-badge chassis so toggle and
* navigation rows read as one consistent list.
*/
const IntegrationToggleRow: React.FC<IntegrationToggleRowProps> = ({
icon: Icon,
title,
description,
checked,
onChange,
}) => {
return (
<label className='flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left'>
<span
className={clsx(
'flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full',
'bg-base-200 text-base-content/70',
)}
>
<Icon className='h-5 w-5' />
</span>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<SettingLabel>{title}</SettingLabel>
<span className='text-base-content/65 truncate text-[0.85em]'>{description}</span>
</div>
<input
type='checkbox'
className='toggle flex-shrink-0'
checked={checked}
onChange={onChange}
/>
</label>
);
};
export default IntegrationsPanel;