Files
readest/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
T
Huang Xin ea99106677 fix(sync): silence third-party cloud-sync error toasts (#4845)
* fix(sync): never toast third-party cloud-sync errors; log to console only

The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.

Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): surface an expired cloud-sync session in the reader + Settings

With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:

- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
  Settings.") — the same affordance as the native "Reading Progress Synced"
  hint. De-duplicated via a per-instance ref so it shows once, not on every
  page-turn sync; reset on a successful sync / provider switch (web reconnect
  reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
  expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
  prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
  in sessionStorage; native auto-refreshes so it doesn't apply there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:29:14 +02:00

198 lines
6.9 KiB
TypeScript

import clsx from 'clsx';
import React, { useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import {
runGoogleDriveConnect,
runGoogleDriveDisconnect,
} from '@/services/sync/providers/gdrive/googleDriveConnect';
import { hasValidWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore';
import { Tips } from '../primitives';
import FileSyncForm from './FileSyncForm';
import { withActiveCloudProvider } from './cloudSync';
const disconnectButtonClass = clsx(
'eink-bordered',
'h-10 rounded-lg px-4 text-sm font-medium',
'text-error hover:bg-error/10',
'transition-colors duration-150',
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
);
const primaryButtonClass = clsx(
'btn btn-contrast',
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
'focus-visible:ring-base-content/40 focus-visible:outline-none focus-visible:ring-2',
);
/**
* Google Drive provider panel, embedded in the Integrations Google Drive
* sub-page (which owns the header). Three states:
*
* - **Active** (`googleDrive.enabled`): the shared {@link FileSyncForm} controls
* + Disconnect (which clears the keychain token — a full teardown).
* - **Configured but inactive** (a token exists — `accountLabel` is set — but
* another provider is active): "Use Google Drive" re-activates it WITHOUT a
* fresh sign-in, so switching back is frictionless; Disconnect tears it down.
* - **Not connected**: the OAuth Connect button.
*
* Activating makes Drive the single active cloud provider (turns WebDAV off).
*/
const GoogleDriveForm: React.FC = () => {
const _ = useTranslation();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { envConfig } = useEnv();
const stored = settings.googleDrive;
const isActive = !!stored?.enabled;
const isConfigured = !!stored?.accountLabel;
const [isConnecting, setIsConnecting] = useState(false);
// On web the access token is short-lived (no refresh token) and lives in
// sessionStorage, so an active connection can sit with an expired/absent
// token. When that happens, swap Disconnect → Reconnect and disable Sync now;
// the user-facing "session expired" notice is a one-time toast in the reader
// (see useFileSync), not a hint here.
const sessionExpired = isActive && isWebAppPlatform() && !hasValidWebDriveToken();
const persistGDrive = async (patch: Partial<typeof stored>) => {
const latest = useSettingsStore.getState().settings;
const next = { ...latest, googleDrive: { ...latest.googleDrive, ...patch } };
setSettings(next);
await saveSettings(envConfig, next);
};
// Make Drive the active provider (turns WebDAV off), optionally stamping a
// freshly-resolved account label.
const activate = async (accountLabel?: string) => {
const latest = useSettingsStore.getState().settings;
const withLabel =
accountLabel === undefined
? latest
: { ...latest, googleDrive: { ...latest.googleDrive, accountLabel } };
const next = withActiveCloudProvider(withLabel, 'gdrive');
setSettings(next);
await saveSettings(envConfig, next);
};
const handleConnect = async () => {
if (isConnecting) return;
setIsConnecting(true);
try {
const { accountLabel } = await runGoogleDriveConnect();
// Only mark connected after the token persisted (runGoogleDriveConnect
// throws if the keychain save fails).
await activate(accountLabel ?? undefined);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
} catch (e) {
console.warn('[gdrive] connect failed', e);
eventDispatcher.dispatch('toast', { type: 'error', message: _('Failed to connect') });
} finally {
setIsConnecting(false);
}
};
const handleActivate = async () => {
await activate();
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
};
const handleDisconnect = async () => {
await runGoogleDriveDisconnect();
const latest = useSettingsStore.getState().settings;
const base = withActiveCloudProvider(latest, null);
const next = { ...base, googleDrive: { ...base.googleDrive, accountLabel: undefined } };
setSettings(next);
await saveSettings(envConfig, next);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
};
if (isActive) {
return (
<div className='space-y-5'>
<FileSyncForm
kind='gdrive'
stored={stored}
persist={persistGDrive}
syncNowDisabled={sessionExpired}
/>
<div className='flex justify-end'>
{sessionExpired ? (
<button
type='button'
onClick={handleConnect}
disabled={isConnecting}
className={clsx(primaryButtonClass, isConnecting && 'opacity-60')}
>
{isConnecting ? (
<>
<span className='loading loading-spinner loading-sm' />
{_('Waiting for sign-in…')}
</>
) : (
_('Reconnect')
)}
</button>
) : (
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
{_('Disconnect')}
</button>
)}
</div>
</div>
);
}
if (isConfigured) {
return (
<div className='space-y-5'>
<Tips>
<li>
{_('Connected as {{account}}', { account: stored.accountLabel })}
{_('. Make Google Drive the active cloud provider.')}
</li>
</Tips>
<div className='flex justify-end gap-2'>
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
{_('Disconnect')}
</button>
<button type='button' onClick={handleActivate} className={primaryButtonClass}>
{_('Use Google Drive')}
</button>
</div>
</div>
);
}
return (
<div className='space-y-5'>
<div className='flex justify-end pt-1'>
<button
type='button'
onClick={handleConnect}
disabled={isConnecting}
className={clsx(primaryButtonClass, isConnecting && 'opacity-60')}
>
{isConnecting ? (
<>
<span className='loading loading-spinner loading-sm' />
{_('Waiting for sign-in…')}
</>
) : (
_('Connect')
)}
</button>
</div>
<Tips>
<li>{_('Sign-in opens in your browser.')}</li>
<li>{_('Readest only accesses the files it creates in your Drive.')}</li>
</Tips>
</div>
);
};
export default GoogleDriveForm;