forked from akai/readest
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>
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
|
||||
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` + `updateBookMetadata`
|
||||
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`; incremental + Full Sync
|
||||
- [Third-party library auto-sync (#4835)](third-party-library-autosync-4835.md) — `useLibraryFileSync` parity w/ `useBooksSync`; `library.json` on import/delete/close; delete propagation NEEDS full library (incl. deletedAt) passed to `syncLibrary`
|
||||
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure; `getState().settings`
|
||||
- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from `SETTINGS_WHITELIST`/`ENCRYPTED_FIELDS`; new nested whitelist group NEEDS matching `mergeSettings` deep-merge or shallow merge clobbers per-device siblings
|
||||
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared settings.json; cross-window broadcast `settingsSync.ts`
|
||||
@@ -72,6 +73,7 @@
|
||||
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS pinch=`ctrl+wheel`; `isWheelZooming` debounce
|
||||
- [Instant Highlight ate tap/swipe](instant-highlight-tap-paginate.md) — preventDefault killed tap-paginate; `INSTANT_HOLD_MS=300`
|
||||
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word; `onAdjustTextSelection`
|
||||
- [Double-click word select](iframe-double-click-word-select.md) — orphaned `iframe-double-click` → `handleDoubleClick` selects word via `getWordRangeFromPoint`/`Intl.Segmenter`, routes through `makeSelection`; `pointerDownTimeRef=0` bypasses hold gate; `isValidSelection` guard avoids desktop double-fire
|
||||
- [Cross-page selection auto-turn (#4741)](cross-page-selection-autoturn-4741.md) — `useAutoPageTurn` dwell; `onAfterTurn` re-emit
|
||||
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `useRendererInputListeners` once-per-view
|
||||
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — snapshot live Set; resume from fresh `lastLocation.cfi`
|
||||
@@ -93,6 +95,7 @@
|
||||
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place `deletedAt`; re-check at READ
|
||||
- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate eagerly creates placeholder; track `notebookNewHighlightId`, presentation-driven cleanup effect
|
||||
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare → stale override; value compare
|
||||
- [Customize Toolbar e-ink black bar (#4839)](customize-toolbar-eink-black-bar-4839.md) — preview Zone copied `bg-gray-600` w/o `.popup-container`; add `eink-bordered` (+ `eink:text-base-content` hint)
|
||||
- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [offline halt #4613](native-tts-offline-autoadvance-4613.md) advance only on `end`, SKIP-on-error via `forward()`
|
||||
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md) `audio.metadata` WordBoundary by rAF gate on UA; [drift](tts-word-highlight-singletextnode-drift.md) TEXT_NODE fast path ignored offsets, slice `[start,end]`
|
||||
- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords` on granularity (NOT supportsWordBoundaries); suppress = wordBoundaries && word
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: customize-toolbar-eink-black-bar-4839
|
||||
description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`.
|
||||
|
||||
**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container` → `bg base-100` + 1px `base-content` border). The preview Zone is a plain `<div>` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black.
|
||||
|
||||
**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70` → `not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are `<button>`s, already inverted to base-content by the global `[data-eink] button` rule. Guard: render test asserts `.selection-popup` element carries `eink-bordered`. Verify rendered colors via `getComputedStyle` under `[data-eink]` (set `data-theme='default-light'` first or theme vars are unresolved → transparent); note daisyUI returns **oklch** not rgb — e-ink correct = bg `oklch(1 0 0)`, border/icon `oklch(0.2 0 0)`. PR #4841.
|
||||
|
||||
Same feature as [[customize-toolbar-global-serializeconfig]]; e-ink conventions in [[feedback_design_system_doc]].
|
||||
@@ -66,6 +66,12 @@ Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync
|
||||
|
||||
**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit.
|
||||
|
||||
**Google Drive on WEB via FULL-PAGE REDIRECT OAuth — DONE on branch `feat/gdrive-web-oauth` (was `feat/gdrive-web-gis`; local/unpushed; suite 6516 green).**
|
||||
- **GIS popup ABANDONED:** `src/middleware.ts:55` sets `Cross-Origin-Opener-Policy: same-origin` on every web doc (Turso WASM/SharedArrayBuffer needs `crossOriginIsolated`). COOP same-origin SEVERS a cross-origin popup's opener handle → GIS's `popup.closed` poll reads true instantly → `popup_closed` fires while the popup is still open (diagnosed live). Can't relax COOP (breaks Turso); can't scope it (connect happens over Turso routes). So no popup OAuth on web.
|
||||
- **Web flow:** full-page redirect (no `window.opener`, works under COOP). `auth/webRedirectFlow.ts` (implicit `response_type=token` — secretless Web client can't code-exchange; CSRF state+returnPath in sessionStorage; parse token from callback fragment) + `auth/webTokenStore.ts` (sessionStorage access token, no refresh token) + `WebDriveAuth.ts` (reads stored token, expired→AUTH_FAILED, `accountLabel` via about.get) + `app/gdrive-callback/page.tsx` (validates state, stores token, `withActiveCloudProvider(settings,'gdrive')`+label via `appService.load/saveSettings`, routes back). `buildGoogleDriveProvider` web branch: `new WebDriveAuth(globalThis.fetch)` (Drive REST CORS-ok; streaming Tauri-only→web buffered). `googleDriveConnect` web: Connect=`beginWebDriveRedirect` (navigates away, never resolves), Disconnect=`clearWebDriveToken`.
|
||||
- **Official Web client id BAKED** `209390247301-585tc3dohg4c02588uvah5d32hg6dneq` (`getGoogleWebClientId`, env `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` overrides). **NO auto-refresh** (secretless browser client → no refresh token; Google blocks hidden-iframe silent renewal) → user reconnects per session; true auto-refresh needs a server-side token broker (Worker holds secret+refresh token) — deferred ("A for now").
|
||||
- **OPS REMAINING:** add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's **Authorized redirect URIs** (JS origins already set). Then live-verify `pnpm dev-web`.
|
||||
|
||||
**PR3 REMAINING:**
|
||||
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as <email>" → add book / Sync now → confirm `Readest/books/<hash>/{config.json,cover.png}` in Drive. Windows cold-browser fallback.
|
||||
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync`→`useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: iframe-double-click-word-select
|
||||
description: Double-click / touch double-tap on a word selects it and fires the instant action or annotation toolbar
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: bac4ae5d-047f-4b4f-8a04-b239beb4d7d7
|
||||
---
|
||||
|
||||
Double-tap (touch) / double-click (mouse) on a word now selects that word — like
|
||||
a long-press — then runs the configured instant quick action, or raises the
|
||||
annotation toolbar if none is set. Verified live on Xiaomi 12 (Android).
|
||||
|
||||
**The gap:** `iframe-double-click` was posted by `handleClick`
|
||||
(`src/app/reader/utils/iframeEventHandlers.ts`, gated on `!doubleClickDisabled`)
|
||||
but had **no consumer** — a touch double-tap did nothing (Android has no native
|
||||
double-tap word-select; desktop double-click already selects natively via the
|
||||
`handlePointerUp` path).
|
||||
|
||||
**Impl (3 files):**
|
||||
- `src/utils/sel.ts`: `getWordRangeAt(node, offset)` expands a caret to the
|
||||
word-like segment via `Intl.Segmenter` (CJK + Latin), `[start,end]` inclusive
|
||||
so a boundary caret still selects the adjacent word; `getWordRangeFromPoint(doc,x,y)`
|
||||
resolves the caret (`caretPositionFromPoint`/`caretRangeFromPoint`) then delegates.
|
||||
- `useTextSelector.ts`: `handleDoubleClick(doc, index, x, y)` selects the word and
|
||||
routes through the existing `makeSelection` (guarded so the programmatic
|
||||
`selectionchange` echo is ignored). **Guard `if (isValidSelection(sel)) return`**
|
||||
— on desktop the browser already selected the word natively (flows through
|
||||
`handlePointerUp`), so synthesize ONLY when nothing is selected (touch double-tap).
|
||||
No `isUpToPopup` latch: a double-tap is two taps both consumed by double-click
|
||||
detection, so no trailing single-click follows that would dismiss the popup.
|
||||
- `Annotator.tsx`: window `message` listener for `iframe-double-click` resolves the
|
||||
visible section doc/index like `handleNativeTouch` (`renderer.getContents()` +
|
||||
`primaryIndex`), then sets **`pointerDownTimeRef.current = 0`** before calling
|
||||
`handleDoubleClick` so the deliberate double-tap bypasses `handleQuickAction`'s
|
||||
`quickActionMinHoldMs` (300ms) long-press gate (mouse already uses 0). Coords:
|
||||
`clientX/clientY` from the iframe click are already section-doc-relative, exactly
|
||||
what caretFromPoint wants — no window↔frame mapping (unlike `rangeFromAnchorToPoint`).
|
||||
|
||||
The branch decision (instant action vs toolbar) reuses the existing Annotator
|
||||
`selection` effect: `enableAnnotationQuickActions && annotationQuickAction &&
|
||||
isTextSelected.current ? handleQuickAction() : handleShowAnnotPopup()`. Default
|
||||
config has `annotationQuickAction: null` → toolbar.
|
||||
|
||||
**Tests:** unit `sel.test.ts` (getWordRangeAt/FromPoint), `useTextSelector-doubleClick.test.ts`
|
||||
(selection routing + desktop guard); e2e `double-click.android.test.ts` + `doubleTap`
|
||||
helper in `helpers/adb.ts` (two `input tap` in one shell, < 250ms apart). Live CDP
|
||||
verify: toolbar branch (`.popup-container.selection-popup`) and instant-action
|
||||
branch (set quick action to Dictionary via header dropdown → `.popup-container.select-text`,
|
||||
toolbar absent). See [[dblclick-drag-pageturn-4524]], [[instant-highlight-tap-paginate]],
|
||||
[[tap-to-open-image-table-4600]].
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: third-party-library-autosync-4835
|
||||
description: Third-party cloud sync (WebDAV/Drive) library.json auto-sync on import/delete/close — parity with useBooksSync; delete propagation needs full library
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
|
||||
---
|
||||
|
||||
PR #4835 (`feat/third-party-library-autosync`). Adds library-scoped auto-sync for the active third-party file-sync provider so `library.json` stays current without a manual "Sync now".
|
||||
|
||||
**Architecture split (important):**
|
||||
- `library.json` (the remote index) is written ONLY by `engine.syncLibrary` (`src/services/sync/file/engine.ts`). Before this PR that was called from exactly ONE place: the Settings → "Sync now" button (`FileSyncForm.tsx`).
|
||||
- The reader's `useFileSync` (`app/reader/hooks/`) is PER-BOOK (progress/notes/cover/file) and NEVER touches `library.json` — it's the analogue of `useProgressSync`, not `useBooksSync`.
|
||||
- So nothing auto-updated `library.json` on import/delete/book-close. Native sync didn't have this gap because `useBooksSync` is library-scoped.
|
||||
|
||||
**Fix:** `useLibraryFileSync()` (`app/library/hooks/useLibraryFileSync.ts`), mounted once on the library page next to `useBooksSync()`. Parity counterpart of `useBooksSync`:
|
||||
- Single `useEffect([library])` → debounced (5s) `engine.syncLibrary`. import (adds row), delete (sets `deletedAt`), book-close (bumps `updatedAt`) all mutate `library`, so one effect covers all three + initial-load pull.
|
||||
- Builds engine async (Drive keychain probe), keyed on connection-relevant settings (NOT lastSyncedAt). Stable debounced trigger via `runSyncRef` so it isn't lost on re-creation.
|
||||
- Gated on global file-sync mutex (`fileSyncStore.beginSync` — skip if a manual Sync now holds it), Sync Strategy, Upload Book Files, and `isCloudSyncAllowed`.
|
||||
- MUST gate on `libraryLoaded` — syncing a transient empty pre-load library would push an empty index and clobber remote.
|
||||
|
||||
**Delete propagation gotcha (the key insight):** `engine.syncLibrary` tombstones a deleted book in `library.json` ONLY if the deleted book (with `deletedAt`) is in the `books` arg → it stays in `allBooksMap` → final index carries the tombstone. If filtered out (the old `FileSyncForm` passed `eligibleBooks = filter(!deletedAt)`), then (1) no tombstone AND (2) the discovery books-dir scan (`!allBooksMap.has(hash)`) RE-DOWNLOADS the just-deleted book (its remote hash dir lingers until the separate GC sweep). So BOTH the hook and `FileSyncForm` now pass the FULL library incl. soft-deleted. Engine tests in `engine-metadata-sync.test.ts`.
|
||||
|
||||
**Scope:** pushes the deletion tombstone to the index (peers won't re-pull it). Does NOT auto-remove the book from a peer's LOCAL library — engine reconcile skips `rb.deletedAt` entries (`engine.ts` ~line 430). Peer-side local deletion is a future, riskier change.
|
||||
|
||||
See [[gdrive-provider-multipr-status]] · [[webdav-metadata-sync-4756]] · [[webdav-filesync-refactor-plan]].
|
||||
@@ -2,12 +2,28 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key><string>Readest</string>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundlePackageType</key><string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Readest</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.11.12</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+8
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import {
|
||||
clearWebDriveToken,
|
||||
hasValidWebDriveToken,
|
||||
loadWebDriveToken,
|
||||
saveWebDriveToken,
|
||||
} from '@/services/sync/providers/gdrive/auth/webTokenStore';
|
||||
@@ -26,4 +27,11 @@ describe('webTokenStore', () => {
|
||||
window.sessionStorage.setItem('gdrive_web_token', 'not json');
|
||||
expect(loadWebDriveToken()).toBeNull();
|
||||
});
|
||||
|
||||
test('hasValidWebDriveToken reflects presence + expiry', () => {
|
||||
expect(hasValidWebDriveToken(1_000)).toBe(false); // none stored
|
||||
saveWebDriveToken({ accessToken: 'AT', expiresAt: 5_000 });
|
||||
expect(hasValidWebDriveToken(1_000)).toBe(true); // not yet expired
|
||||
expect(hasValidWebDriveToken(9_000)).toBe(false); // expired
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookProgress } from '@/store/readerProgressStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { isCloudSyncAllowed } from '@/utils/access';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
@@ -95,6 +95,8 @@ export const useFileSync = (bookKey: string) => {
|
||||
const fileSyncedRef = useRef(false);
|
||||
/** Per-instance lock for the cover uploader (gated differently than files). */
|
||||
const coverSyncedRef = useRef(false);
|
||||
/** One-shot guard so an expired session toasts once, not on every page-turn. */
|
||||
const authNotifiedRef = useRef(false);
|
||||
|
||||
// Switching the active provider mid-session resets the per-book locks so the
|
||||
// newly-active backend does a fresh pull-on-open and re-checks file/cover.
|
||||
@@ -104,6 +106,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
coverSyncedRef.current = false;
|
||||
lastPulledAtRef.current = 0;
|
||||
dirtyRef.current = false;
|
||||
authNotifiedRef.current = false;
|
||||
}, [activeKind]);
|
||||
|
||||
// Read latest settings from the store (not the closure) when patching the
|
||||
@@ -187,12 +190,33 @@ export const useFileSync = (bookKey: string) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engineKey, isReady, activeKind, appService, envConfig]);
|
||||
|
||||
const authFailedToast = useCallback(() => {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Cloud sync authentication failed. Reconnect in Settings.'),
|
||||
/**
|
||||
* Notify (once) that the active provider's session expired so the user knows
|
||||
* to reconnect — a single top-right reader `hint` (same affordance as the
|
||||
* native "Reading Progress Synced" hint), NOT a per-failure error toast. Reset
|
||||
* on a successful sync / provider switch.
|
||||
*/
|
||||
const notifyAuthExpiredOnce = useCallback(() => {
|
||||
if (authNotifiedRef.current) return;
|
||||
authNotifiedRef.current = true;
|
||||
eventDispatcher.dispatch('hint', {
|
||||
bookKey,
|
||||
timeout: 5000,
|
||||
message:
|
||||
activeKind === 'gdrive'
|
||||
? _('Google Drive session expired. Reconnect in Settings.')
|
||||
: _('Cloud sync session expired. Reconnect in Settings.'),
|
||||
});
|
||||
}, [_]);
|
||||
}, [bookKey, activeKind, _]);
|
||||
|
||||
/** Map a sync error: surface an expired session once, log everything. */
|
||||
const handleSyncError = useCallback(
|
||||
(label: string, e: unknown) => {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') notifyAuthExpiredOnce();
|
||||
console.warn(label, e);
|
||||
},
|
||||
[notifyAuthExpiredOnce],
|
||||
);
|
||||
|
||||
/**
|
||||
* Push the latest config (progress + booknotes) to the remote. Skips while the
|
||||
@@ -214,10 +238,10 @@ export const useFileSync = (bookKey: string) => {
|
||||
const deviceId = ensureDeviceId();
|
||||
await engine.pushBookConfig(book, config, deviceId);
|
||||
dirtyRef.current = false;
|
||||
authNotifiedRef.current = false;
|
||||
await updateLastSyncedAt(Date.now());
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
|
||||
else console.warn('file sync push failed', e);
|
||||
handleSyncError('file sync push failed', e);
|
||||
}
|
||||
}, [
|
||||
allowPush,
|
||||
@@ -228,7 +252,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
engine,
|
||||
providerSettings,
|
||||
updateLastSyncedAt,
|
||||
authFailedToast,
|
||||
handleSyncError,
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -251,8 +275,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
} catch (e) {
|
||||
// Reset the lock on failure so a later trigger retries.
|
||||
fileSyncedRef.current = false;
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
|
||||
else console.warn('file sync book push failed', e);
|
||||
handleSyncError('file sync book push failed', e);
|
||||
}
|
||||
}, [
|
||||
allowPush,
|
||||
@@ -261,7 +284,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
bookKey,
|
||||
engine,
|
||||
updateLastSyncedAt,
|
||||
authFailedToast,
|
||||
handleSyncError,
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -281,10 +304,9 @@ export const useFileSync = (bookKey: string) => {
|
||||
await engine.pushBookCover(book);
|
||||
} catch (e) {
|
||||
coverSyncedRef.current = false;
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
|
||||
else console.warn('file sync cover push failed', e);
|
||||
handleSyncError('file sync cover push failed', e);
|
||||
}
|
||||
}, [allowPush, getBookData, bookKey, engine, authFailedToast]);
|
||||
}, [allowPush, getBookData, bookKey, engine, handleSyncError]);
|
||||
|
||||
/**
|
||||
* Pull, merge, and persist, using the same per-config / per-note merge as the
|
||||
@@ -304,6 +326,8 @@ export const useFileSync = (bookKey: string) => {
|
||||
try {
|
||||
const result = await engine.pullBookConfig(book, config);
|
||||
lastPulledAtRef.current = Date.now();
|
||||
// The pull's getAccessToken succeeded — clear any expired-session notice.
|
||||
authNotifiedRef.current = false;
|
||||
if (!result.applied || !result.mergedConfig) return false;
|
||||
|
||||
// Surface merged notes through the live view so highlights re-appear /
|
||||
@@ -342,8 +366,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
await updateLastSyncedAt(Date.now());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
|
||||
else console.warn('file sync pull failed', e);
|
||||
handleSyncError('file sync pull failed', e);
|
||||
return false;
|
||||
}
|
||||
}, [
|
||||
@@ -360,7 +383,7 @@ export const useFileSync = (bookKey: string) => {
|
||||
settings,
|
||||
providerSettings,
|
||||
updateLastSyncedAt,
|
||||
authFailedToast,
|
||||
handleSyncError,
|
||||
]);
|
||||
|
||||
// Stash the latest callbacks in a ref so the event-bridge effect doesn't
|
||||
|
||||
@@ -35,6 +35,12 @@ interface FileSyncFormProps {
|
||||
stored: FileSyncFormSettings;
|
||||
/** Persist a patch into this backend's settings slice (must merge store-latest). */
|
||||
persist: (patch: Partial<FileSyncFormSettings>) => Promise<void>;
|
||||
/**
|
||||
* Disable the "Sync now" button — set when the connection needs attention
|
||||
* (e.g. an expired web Google Drive session) so a manual sync that would just
|
||||
* fail isn't offered. The parent panel shows the reconnect affordance.
|
||||
*/
|
||||
syncNowDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +73,12 @@ const formatSyncError = (_: TranslationFunc, e: unknown): string => {
|
||||
* form; everything below the connect line is identical across backends, so it
|
||||
* lives here once and is parameterised by {@link FileSyncFormProps.kind}.
|
||||
*/
|
||||
const FileSyncForm: React.FC<FileSyncFormProps> = ({ kind, stored, persist }) => {
|
||||
const FileSyncForm: React.FC<FileSyncFormProps> = ({
|
||||
kind,
|
||||
stored,
|
||||
persist,
|
||||
syncNowDisabled = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { settings } = useSettingsStore();
|
||||
const { envConfig } = useEnv();
|
||||
@@ -92,6 +103,7 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({ kind, stored, persist }) =>
|
||||
* provider is built by kind through the registry so this stays backend-neutral.
|
||||
*/
|
||||
const handleSyncNow = async () => {
|
||||
if (syncNowDisabled) return;
|
||||
if (useFileSyncStore.getState().byKind[kind]?.isSyncing) return;
|
||||
if (!stored.enabled) return;
|
||||
|
||||
@@ -213,8 +225,11 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({ kind, stored, persist }) =>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSyncNow}
|
||||
disabled={isSyncing}
|
||||
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2', isSyncing && 'opacity-60')}
|
||||
disabled={isSyncing || syncNowDisabled}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2',
|
||||
(isSyncing || syncNowDisabled) && 'opacity-60',
|
||||
)}
|
||||
title={_('Sync now')}
|
||||
aria-label={_('Sync now')}
|
||||
>
|
||||
|
||||
@@ -2,12 +2,14 @@ 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';
|
||||
@@ -49,6 +51,13 @@ const GoogleDriveForm: React.FC = () => {
|
||||
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 } };
|
||||
@@ -104,11 +113,34 @@ const GoogleDriveForm: React.FC = () => {
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<FileSyncForm kind='gdrive' stored={stored} persist={persistGDrive} />
|
||||
<FileSyncForm
|
||||
kind='gdrive'
|
||||
stored={stored}
|
||||
persist={persistGDrive}
|
||||
syncNowDisabled={sessionExpired}
|
||||
/>
|
||||
<div className='flex justify-end'>
|
||||
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
|
||||
{_('Disconnect')}
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -41,3 +41,13 @@ export const loadWebDriveToken = (): TokenSet | null => {
|
||||
export const clearWebDriveToken = (): void => {
|
||||
getStorage()?.removeItem(WEB_TOKEN_KEY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a usable (non-expired) access token is stored. Lets the settings panel
|
||||
* show a "session expired" hint + Reconnect when the web token is gone/expired
|
||||
* (the connection stays `enabled`, but the short-lived token must be re-minted).
|
||||
*/
|
||||
export const hasValidWebDriveToken = (now: number = Date.now()): boolean => {
|
||||
const tokens = loadWebDriveToken();
|
||||
return !!tokens && now < tokens.expiresAt;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user