feat(sync): more intuitive pull down to sync gesture and toast info, closes #3128 (#3131)

This commit is contained in:
Huang Xin
2026-02-01 16:23:24 +08:00
committed by GitHub
parent 1294ef90c1
commit 94930baa1b
33 changed files with 420 additions and 122 deletions
@@ -1006,5 +1006,19 @@
"Finished": "منتهى",
"Unread": "غير مقروء",
"Clear Status": "مسح الحالة",
"Status": "الحالة"
"Status": "الحالة",
"Set status for {{count}} book(s)_zero": "تعيين الحالة للكتب",
"Set status for {{count}} book(s)_two": "تعيين الحالة للكتابين",
"Set status for {{count}} book(s)_few": "تعيين الحالة لـ {{count}} كتب",
"Set status for {{count}} book(s)_many": "تعيين الحالة لـ {{count}} كتاباً",
"Loading": "جارٍ التحميل...",
"Exit Paragraph Mode": "الخروج من وضع الفقرة",
"Paragraph Mode": "وضع الفقرة",
"Embedding Model": "نموذج التضمين",
"{{count}} book(s) synced_zero": "لم تتم مزامنة أي كتب",
"{{count}} book(s) synced_one": "تمت مزامنة كتاب واحد",
"{{count}} book(s) synced_two": "تمت مزامنة كتابين",
"{{count}} book(s) synced_few": "تمت مزامنة {{count}} كتب",
"{{count}} book(s) synced_many": "تمت مزامنة {{count}} كتاباً",
"{{count}} book(s) synced_other": "تمت مزامنة {{count}} كتاب"
}
@@ -966,5 +966,11 @@
"Finished": "শেষ হয়েছে",
"Unread": "অপঠিত",
"Clear Status": "স্থিতি মুছুন",
"Status": "অবস্থা"
"Status": "অবস্থা",
"Loading": "লোড হচ্ছে...",
"Exit Paragraph Mode": "অনুচ্ছেদ মোড থেকে প্রস্থান করুন",
"Paragraph Mode": "অনুচ্ছেদ মোড",
"Embedding Model": "এম্বেডিং মডেল",
"{{count}} book(s) synced_one": "{{count}}টি বই সিঙ্ক করা হয়েছে",
"{{count}} book(s) synced_other": "{{count}}টি বই সিঙ্ক করা হয়েছে"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "AI Gateway དཔེ་གཞི།",
"Actions": "བྱ་འགུལ།",
"Navigation": "འགྲུལ་བཞུད།",
"Set status for {{count}} book(s)_one": "དེབ་ {{count}} གི་གནས་སྟངས་གཏན་འཁེལ་བྱེད།",
"Set status for {{count}} book(s)_other": "དེབ་ {{count}} གི་གནས་སྟངས་གཏན་འཁེལ་བྱེད།",
"Mark as Unread": "མ་ཀློག་པར་རྟགས་རྒྱག",
"Mark as Finished": "ཚར་བར་རྟགས་རྒྱག",
"Finished": "ཚར་སོང་།",
"Unread": "མ་ཀློག་པ།",
"Clear Status": "གནས་སྟངས་གཙང་མ།",
"Status": "གནས་སྟངས།"
"Status": "གནས་སྟངས།",
"Loading": "ལེན་བཞིན་པ།...",
"Exit Paragraph Mode": "དུམ་མཚམས་རྣམ་པ་ནས་ཕྱིར་ཐོན།",
"Paragraph Mode": "དུམ་མཚམས་རྣམ་པ།",
"Embedding Model": "གནས་སྒྲིག་དཔེ་གཞི།",
"{{count}} book(s) synced_other": "དེབ་ {{count}} མཉམ་འགྲིག་བྱས་ཟིན།"
}
@@ -966,5 +966,11 @@
"Finished": "Beendet",
"Unread": "Ungelesen",
"Clear Status": "Status löschen",
"Status": "Status"
"Status": "Status",
"Loading": "Laden...",
"Exit Paragraph Mode": "Absatzmodus verlassen",
"Paragraph Mode": "Absatzmodus",
"Embedding Model": "Einbettungsmodell",
"{{count}} book(s) synced_one": "{{count}} Buch synchronisiert",
"{{count}} book(s) synced_other": "{{count}} Bücher synchronisiert"
}
@@ -966,5 +966,11 @@
"Finished": "Ολοκληρώθηκε",
"Unread": "Μη αναγνωσμένο",
"Clear Status": "Εκκαθάριση κατάστασης",
"Status": "Κατάσταση"
"Status": "Κατάσταση",
"Loading": "Φόρτωση...",
"Exit Paragraph Mode": "Έξοδος από τη λειτουργία παραγράφου",
"Paragraph Mode": "Λειτουργία παραγράφου",
"Embedding Model": "Μοντέλο ενσωμάτωσης",
"{{count}} book(s) synced_one": "{{count}} βιβλίο συγχρονίστηκε",
"{{count}} book(s) synced_other": "{{count}} βιβλία συγχρονίστηκαν"
}
@@ -22,10 +22,6 @@
"Successfully imported {{count}} book(s)_other": "Successfully imported {{count}} books",
"Set status for {{count}} book(s)_one": "Set status for {{count}} book",
"Set status for {{count}} book(s)_other": "Set status for {{count}} books",
"Mark as Finished": "Mark as Finished",
"Mark as Unread": "Mark as Unread",
"Finished": "Finished",
"Unread": "Unread",
"Clear Status": "Clear Status",
"Status": "Status"
"{{count}} book(s) synced_one": "{{count}} book synced",
"{{count}} book(s) synced_other": "{{count}} books synced"
}
@@ -976,5 +976,13 @@
"Finished": "Terminado",
"Unread": "Sin leer",
"Clear Status": "Borrar estado",
"Status": "Estado"
"Status": "Estado",
"Set status for {{count}} book(s)_many": "Establecer estado para {{count}} libros",
"Loading": "Cargando...",
"Exit Paragraph Mode": "Salir del modo de párrafo",
"Paragraph Mode": "Modo de párrafo",
"Embedding Model": "Modelo de incrustación",
"{{count}} book(s) synced_one": "{{count}} libro sincronizado",
"{{count}} book(s) synced_many": "{{count}} libros sincronizados",
"{{count}} book(s) synced_other": "{{count}} libros sincronizados"
}
@@ -966,5 +966,11 @@
"Finished": "تمام‌شده",
"Unread": "خوانده‌نشده",
"Clear Status": "پاک کردن وضعیت",
"Status": "وضعیت"
"Status": "وضعیت",
"Loading": "در حال بارگذاری...",
"Exit Paragraph Mode": "خروج از حالت بند",
"Paragraph Mode": "حالت بند",
"Embedding Model": "مدل جاسازی",
"{{count}} book(s) synced_one": "{{count}} کتاب همگام شد",
"{{count}} book(s) synced_other": "{{count}} کتاب همگام شدند"
}
@@ -976,5 +976,13 @@
"Finished": "Terminé",
"Unread": "Non lu",
"Clear Status": "Effacer le statut",
"Status": "Statut"
"Status": "Statut",
"Set status for {{count}} book(s)_many": "Définir le statut pour {{count}} livres",
"Loading": "Chargement...",
"Exit Paragraph Mode": "Quitter le mode paragraphe",
"Paragraph Mode": "Mode paragraphe",
"Embedding Model": "Modèle d'incorporation",
"{{count}} book(s) synced_one": "{{count}} livre synchronisé",
"{{count}} book(s) synced_many": "{{count}} livres synchronisés",
"{{count}} book(s) synced_other": "{{count}} livres synchronisés"
}
@@ -966,5 +966,11 @@
"Finished": "समाप्त",
"Unread": "अपठित",
"Clear Status": "स्थिति साफ़ करें",
"Status": "स्थिति"
"Status": "स्थिति",
"Loading": "लोड हो रहा है...",
"Exit Paragraph Mode": "पैराग्राफ मोड से बाहर निकलें",
"Paragraph Mode": "पैराग्राफ मोड",
"Embedding Model": "एंबेडिंग मॉडल",
"{{count}} book(s) synced_one": "{{count}} पुस्तक सिंक की गई",
"{{count}} book(s) synced_other": "{{count}} पुस्तकें सिंक की गईं"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "Model AI Gateway",
"Actions": "Tindakan",
"Navigation": "Navigasi",
"Set status for {{count}} book(s)_one": "Atur status untuk {{count}} buku",
"Set status for {{count}} book(s)_other": "Atur status untuk {{count}} buku",
"Mark as Unread": "Tandai sebagai Belum Dibaca",
"Mark as Finished": "Tandai sebagai Selesai",
"Finished": "Selesai",
"Unread": "Belum Dibaca",
"Clear Status": "Hapus Status",
"Status": "Status"
"Status": "Status",
"Loading": "Memuat...",
"Exit Paragraph Mode": "Keluar dari Mode Paragraf",
"Paragraph Mode": "Mode Paragraf",
"Embedding Model": "Model Penyematan",
"{{count}} book(s) synced_other": "{{count}} buku telah disinkronkan"
}
@@ -976,5 +976,13 @@
"Finished": "Finito",
"Unread": "Non letto",
"Clear Status": "Cancella stato",
"Status": "Stato"
"Status": "Stato",
"Set status for {{count}} book(s)_many": "Imposta stato per {{count}} libri",
"Loading": "Caricamento...",
"Exit Paragraph Mode": "Esci dalla modalità paragrafo",
"Paragraph Mode": "Modalità paragrafo",
"Embedding Model": "Modello di incorporazione",
"{{count}} book(s) synced_one": "{{count}} libro sincronizzato",
"{{count}} book(s) synced_many": "{{count}} libri sincronizzati",
"{{count}} book(s) synced_other": "{{count}} libri sincronizzati"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "AI Gatewayモデル",
"Actions": "アクション",
"Navigation": "ナビゲーション",
"Set status for {{count}} book(s)_one": "{{count}} 冊の本のステータスを設定",
"Set status for {{count}} book(s)_other": "{{count}} 冊の本のステータスを設定",
"Mark as Unread": "未読にする",
"Mark as Finished": "読了にする",
"Finished": "読了",
"Unread": "未読",
"Clear Status": "ステータスをクリア",
"Status": "ステータス"
"Status": "ステータス",
"Loading": "読み込み中",
"Exit Paragraph Mode": "段落モードを終了",
"Paragraph Mode": "段落モード",
"Embedding Model": "埋め込みモデル",
"{{count}} book(s) synced_other": "{{count}} 冊の本が同期されました"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "AI 게이트웨이 모델",
"Actions": "작업",
"Navigation": "탐색",
"Set status for {{count}} book(s)_one": "책 {{count}}권 상태 설정",
"Set status for {{count}} book(s)_other": "책 {{count}}권 상태 설정",
"Mark as Unread": "읽지 않음으로 표시",
"Mark as Finished": "완료로 표시",
"Finished": "완료",
"Unread": "읽지 않음",
"Clear Status": "상태 지우기",
"Status": "상태"
"Status": "상태",
"Loading": "로드 중...",
"Exit Paragraph Mode": "단락 모드 종료",
"Paragraph Mode": "단락 모드",
"Embedding Model": "임베딩 모델",
"{{count}} book(s) synced_other": "{{count}}권의 책이 동기화되었습니다"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "Model AI Gateway",
"Actions": "Tindakan",
"Navigation": "Navigasi",
"Set status for {{count}} book(s)_one": "Tetapkan status untuk {{count}} buku",
"Set status for {{count}} book(s)_other": "Tetapkan status untuk {{count}} buku",
"Mark as Unread": "Tandakan sebagai Belum Dibaca",
"Mark as Finished": "Tandakan sebagai Selesai",
"Finished": "Selesai",
"Unread": "Belum Dibaca",
"Clear Status": "Kosongkan Status",
"Status": "Status"
"Status": "Status",
"Loading": "Memuatkan...",
"Exit Paragraph Mode": "Keluar dari Mod Perenggan",
"Paragraph Mode": "Mod Perenggan",
"Embedding Model": "Model Benaman",
"{{count}} book(s) synced_other": "{{count}} buku telah disegerakkan"
}
@@ -966,5 +966,11 @@
"Finished": "Voltooid",
"Unread": "Ongelezen",
"Clear Status": "Status wissen",
"Status": "Status"
"Status": "Status",
"Loading": "Laden...",
"Exit Paragraph Mode": "Alineamodus verlaten",
"Paragraph Mode": "Alineamodus",
"Embedding Model": "Insluitmodel",
"{{count}} book(s) synced_one": "{{count}} boek gesynchroniseerd",
"{{count}} book(s) synced_other": "{{count}} boeken gesynchroniseerd"
}
@@ -986,5 +986,15 @@
"Finished": "Ukończone",
"Unread": "Nieprzeczytane",
"Clear Status": "Wyczyść status",
"Status": "Status"
"Status": "Status",
"Set status for {{count}} book(s)_few": "Ustaw status dla {{count}} książek",
"Set status for {{count}} book(s)_many": "Ustaw status dla {{count}} książek",
"Loading": "Ładowanie...",
"Exit Paragraph Mode": "Wyjdź z trybu akapitu",
"Paragraph Mode": "Tryb akapitu",
"Embedding Model": "Model osadzania",
"{{count}} book(s) synced_one": "{{count}} książka zsynchronizowana",
"{{count}} book(s) synced_few": "{{count}} książki zsynchronizowane",
"{{count}} book(s) synced_many": "{{count}} książek zsynchronizowanych",
"{{count}} book(s) synced_other": "{{count}} książek zsynchronizowanych"
}
@@ -976,5 +976,13 @@
"Finished": "Concluído",
"Unread": "Não lido",
"Clear Status": "Limpar estado",
"Status": "Estado"
"Status": "Estado",
"Set status for {{count}} book(s)_many": "Definir status para {{count}} livros",
"Loading": "Carregando...",
"Exit Paragraph Mode": "Sair do modo de parágrafo",
"Paragraph Mode": "Modo de parágrafo",
"Embedding Model": "Modelo de incorporação",
"{{count}} book(s) synced_one": "{{count}} livro sincronizado",
"{{count}} book(s) synced_many": "{{count}} livros sincronizados",
"{{count}} book(s) synced_other": "{{count}} livros sincronizados"
}
@@ -986,5 +986,15 @@
"Finished": "Прочитано",
"Unread": "Не прочитано",
"Clear Status": "Очистить статус",
"Status": "Статус"
"Status": "Статус",
"Set status for {{count}} book(s)_few": "Установить статус для {{count}} книг",
"Set status for {{count}} book(s)_many": "Установить статус для {{count}} книг",
"Loading": "Загрузка...",
"Exit Paragraph Mode": "Выйти из режима абзаца",
"Paragraph Mode": "Режим абзаца",
"Embedding Model": "Модель встраивания",
"{{count}} book(s) synced_one": "{{count}} книга синхронизирована",
"{{count}} book(s) synced_few": "{{count}} книги синхронизированы",
"{{count}} book(s) synced_many": "{{count}} книг синхронизировано",
"{{count}} book(s) synced_other": "{{count}} книг синхронизировано"
}
@@ -966,5 +966,11 @@
"Finished": "අවසන්",
"Unread": "නොකියවූ",
"Clear Status": "තත්ත්වය හිස් කරන්න",
"Status": "තත්ත්වය"
"Status": "තත්ත්වය",
"Loading": "පූරණය වෙමින් පවතී...",
"Exit Paragraph Mode": "ඡේද ප්‍රකාරයෙන් ඉවත් වන්න",
"Paragraph Mode": "ඡේද ප්‍රකාරය",
"Embedding Model": "එබ්බවීමේ ආකෘතිය",
"{{count}} book(s) synced_one": "{{count}} පොතක් සමමුහුර්ත කරන ලදී",
"{{count}} book(s) synced_other": "{{count}} පොත් සමමුහුර්ත කරන ලදී"
}
@@ -966,5 +966,11 @@
"Finished": "Läst",
"Unread": "Oläst",
"Clear Status": "Rensa status",
"Status": "Status"
"Status": "Status",
"Loading": "Laddar...",
"Exit Paragraph Mode": "Avsluta styckeläge",
"Paragraph Mode": "Styckeläge",
"Embedding Model": "Inbäddningsmodell",
"{{count}} book(s) synced_one": "{{count}} bok synkroniserad",
"{{count}} book(s) synced_other": "{{count}} böcker synkroniserade"
}
@@ -966,5 +966,11 @@
"Finished": "முடிந்தது",
"Unread": "படிக்கவில்லை",
"Clear Status": "நிலையை அழி",
"Status": "நிலை"
"Status": "நிலை",
"Loading": "ஏற்றுகிறது...",
"Exit Paragraph Mode": "பத்தி பயன்முறையிலிருந்து வெளியேறு",
"Paragraph Mode": "பத்தி பயன்முறை",
"Embedding Model": "உட்பொதி மாதிரி",
"{{count}} book(s) synced_one": "{{count}} புத்தகம் ஒத்திசைக்கப்பட்டது",
"{{count}} book(s) synced_other": "{{count}} புத்தகங்கள் ஒத்திசைக்கப்பட்டன"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "โมเดล AI Gateway",
"Actions": "การกระทำ",
"Navigation": "การนำทาง",
"Set status for {{count}} book(s)_one": "ตั้งสถานะสำหรับหนังสือ {{count}} เล่ม",
"Set status for {{count}} book(s)_other": "ตั้งสถานะสำหรับหนังสือ {{count}} เล่ม",
"Mark as Unread": "ทำเครื่องหมายว่ายังไม่อ่าน",
"Mark as Finished": "ทำเครื่องหมายว่าอ่านจบแล้ว",
"Finished": "อ่านจบแล้ว",
"Unread": "ยังไม่ได้อ่าน",
"Clear Status": "ล้างสถานะ",
"Status": "สถานะ"
"Status": "สถานะ",
"Loading": "กำลังโหลด...",
"Exit Paragraph Mode": "ออกจากโหมดพารากราฟ",
"Paragraph Mode": "โหมดพารากราฟ",
"Embedding Model": "โมเดลการฝังตัว",
"{{count}} book(s) synced_other": "ซิงค์หนังสือ {{count}} เล่มแล้ว"
}
@@ -966,5 +966,11 @@
"Finished": "Bitirildi",
"Unread": "Okunmadı",
"Clear Status": "Durumu Temizle",
"Status": "Durum"
"Status": "Durum",
"Loading": "Yükleniyor...",
"Exit Paragraph Mode": "Paragraf modundan çık",
"Paragraph Mode": "Paragraf modu",
"Embedding Model": "Gömme Modeli",
"{{count}} book(s) synced_one": "{{count}} kitap senkronize edildi",
"{{count}} book(s) synced_other": "{{count}} kitap senkronize edildi"
}
@@ -986,5 +986,15 @@
"Finished": "Завершено",
"Unread": "Непрочитане",
"Clear Status": "Очистити статус",
"Status": "Статус"
"Status": "Статус",
"Set status for {{count}} book(s)_few": "Встановити статус для {{count}} книг",
"Set status for {{count}} book(s)_many": "Встановити статус для {{count}} книг",
"Loading": "Завантаження...",
"Exit Paragraph Mode": "Вийти з режиму абзацу",
"Paragraph Mode": "Режим абзацу",
"Embedding Model": "Модель вбудовування",
"{{count}} book(s) synced_one": "{{count}} книга синхронізована",
"{{count}} book(s) synced_few": "{{count}} книги синхронізовані",
"{{count}} book(s) synced_many": "{{count}} книг синхронізовано",
"{{count}} book(s) synced_other": "{{count}} книг синхронізовано"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "Mô hình AI Gateway",
"Actions": "Hành động",
"Navigation": "Điều hướng",
"Set status for {{count}} book(s)_one": "Đặt trạng thái cho {{count}} cuốn sách",
"Set status for {{count}} book(s)_other": "Đặt trạng thái cho {{count}} cuốn sách",
"Mark as Unread": "Đánh dấu là chưa đọc",
"Mark as Finished": "Đánh dấu là đã hoàn thành",
"Finished": "Đã hoàn thành",
"Unread": "Chưa đọc",
"Clear Status": "Xóa trạng thái",
"Status": "Trạng thái"
"Status": "Trạng thái",
"Loading": "Đang tải...",
"Exit Paragraph Mode": "Thoát chế độ đoạn văn",
"Paragraph Mode": "Chế độ đoạn văn",
"Embedding Model": "Mô hình nhúng",
"{{count}} book(s) synced_other": "Đã đồng bộ {{count}} cuốn sách"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "AI Gateway 模型",
"Actions": "操作",
"Navigation": "导航",
"Set status for {{count}} book(s)_one": "设置 {{count}} 本书的状态",
"Set status for {{count}} book(s)_other": "设置 {{count}} 本书的状态",
"Mark as Unread": "标记为未读",
"Mark as Finished": "标记为已读",
"Finished": "已读",
"Unread": "未读",
"Clear Status": "清除状态",
"Status": "状态"
"Status": "状态",
"Loading": "正在加载",
"Exit Paragraph Mode": "退出段落模式",
"Paragraph Mode": "段落模式",
"Embedding Model": "嵌入模型",
"{{count}} book(s) synced_other": "{{count}} 本书已同步"
}
@@ -949,12 +949,16 @@
"AI Gateway Model": "AI Gateway 模型",
"Actions": "操作",
"Navigation": "導覽",
"Set status for {{count}} book(s)_one": "設定 {{count}} 本書的狀態",
"Set status for {{count}} book(s)_other": "設定 {{count}} 本書的狀態",
"Mark as Unread": "標示為未讀",
"Mark as Finished": "標示為已讀",
"Finished": "已讀",
"Unread": "未讀",
"Clear Status": "清除狀態",
"Status": "狀態"
"Status": "狀態",
"Loading": "正在載入",
"Exit Paragraph Mode": "退出段落模式",
"Paragraph Mode": "段落模式",
"Embedding Model": "嵌入模型",
"{{count}} book(s) synced_other": "{{count}} 本書已同步"
}
@@ -4,11 +4,14 @@ import { useSync } from '@/hooks/useSync';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
export const useBooksSync = () => {
const _ = useTranslation();
const { user } = useAuth();
const { appService } = useEnv();
const { library, isSyncing, libraryLoaded } = useLibraryStore();
@@ -31,20 +34,33 @@ export const useBooksSync = () => {
};
}, [user, lastSyncedAtBooks]);
const pullLibrary = useCallback(async () => {
if (!user) return;
if (isPullingRef.current) {
console.log('Pull already in progress, skipping...');
return;
}
try {
isPullingRef.current = true;
const library = useLibraryStore.getState().library;
await syncBooks([], 'pull', libraryLoaded && library.length === 0 ? 0 : undefined);
} finally {
isPullingRef.current = false;
}
}, [user, libraryLoaded, syncBooks]);
const pullLibrary = useCallback(
async (fullRefresh = false, verbose = false) => {
if (!user) return;
if (isPullingRef.current) {
console.log('Pull already in progress, skipping...');
return;
}
try {
isPullingRef.current = true;
const library = useLibraryStore.getState().library;
const syncedBooksCount = await syncBooks(
[],
'pull',
(libraryLoaded && library.length === 0) || fullRefresh ? 0 : undefined,
);
if (verbose) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('{{count}} book(s) synced', { count: syncedBooksCount }),
});
}
} finally {
isPullingRef.current = false;
}
},
[_, user, libraryLoaded, syncBooks],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleAutoSync = useCallback(
+5 -1
View File
@@ -132,7 +132,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { pullLibrary, pushLibrary } = useBooksSync();
const { isDragging } = useDragDropImport();
usePullToRefresh(containerRef, pullLibrary);
usePullToRefresh(
containerRef,
pullLibrary.bind(null, false, true),
pullLibrary.bind(null, true, true),
);
useScreenWakeLock(settings.screenWakeLock);
useShortcuts({
+169 -53
View File
@@ -1,22 +1,42 @@
import { useEffect } from 'react';
import { getOSPlatform } from '@/utils/misc';
const TRIGGER_THRESHOLD = 120;
const SHOW_INDICATOR_THRESHOLD = 60;
const TRIGGER_THRESHOLD_STAGE1 = 120;
const TRIGGER_THRESHOLD_STAGE2 = 400;
const MAX_LOADING_POSITION = 80;
const PARALLAX_EFFECT = 0.3;
const MAX = 128;
const k = 0.4;
function appr(x: number) {
return MAX * (1 - Math.exp((-k * x) / MAX));
// Platform-specific damping parameters for pull resistance
const getPlatformDamping = () => {
const platform = getOSPlatform();
if (platform === 'ios') {
// iOS - tighter resistance (lower k = more damping)
return { MAX: 120, k: 0.35 };
} else if (platform === 'android') {
// Android - looser resistance (higher k = less damping)
return { MAX: 140, k: 0.5 };
}
return { MAX: 128, k: 0.4 };
};
function createApprFunction(MAX: number, k: number) {
return (x: number) => MAX * (1 - Math.exp((-k * x) / MAX));
}
export const usePullToRefresh = (
ref: React.RefObject<HTMLDivElement | null>,
onTrigger: () => void,
onTriggerStage1: () => Promise<void> | void,
onTriggerStage2?: () => Promise<void> | void,
) => {
useEffect(() => {
const el = ref.current;
if (!el) return;
const damping = getPlatformDamping();
const appr = createApprFunction(damping.MAX, damping.k);
let isLoading = false;
el.addEventListener('touchstart', handleTouchStart, { passive: true });
function handleTouchStart(startEvent: TouchEvent) {
@@ -34,6 +54,7 @@ export const usePullToRefresh = (
function handleTouchMove(moveEvent: TouchEvent) {
const el = ref.current;
if (!el) return;
if (isLoading) return;
const currentX = moveEvent.touches[0]!.clientX;
const currentY = moveEvent.touches[0]!.clientY;
@@ -41,79 +62,174 @@ export const usePullToRefresh = (
const dy = currentY - initialY;
if (dy < 0 || Math.abs(dx) * 2 > Math.abs(dy)) return;
const transformValue = appr(dy);
const parentEl = el.parentNode as HTMLDivElement;
if (dy > TRIGGER_THRESHOLD) {
flipArrow(parentEl);
} else if (dy > SHOW_INDICATOR_THRESHOLD) {
addPullIndicator(parentEl);
if (dy > 10) {
const opacity = Math.min(1, 0.3 + (dy / TRIGGER_THRESHOLD_STAGE2) * 0.7);
showLoadingSpinner(parentEl, transformValue, opacity);
} else {
removePullIndicator(parentEl);
hideLoadingSpinner(parentEl);
}
// Update loading spinner position and opacity with parallax if it exists
updateLoadingSpinnerPosition(parentEl, transformValue, dy);
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
if (wrapper) {
wrapper.style.transform = `translate3d(0, ${appr(dy)}px, 0)`;
wrapper.style.transform = `translate3d(0, ${transformValue}px, 0)`;
}
}
function addPullIndicator(el: HTMLDivElement) {
const indicator = el.querySelector('.pull-indicator');
if (indicator) {
if (indicator.classList.contains('flip')) {
indicator.classList.remove('flip');
}
return;
}
function showLoadingSpinner(el: HTMLDivElement, transform: number, opacity: number = 1) {
const existing = el.querySelector('.pull-refresh-loading');
if (existing) return;
const headerbar = document.querySelector('.titlebar');
const pullIndicator = document.createElement('div');
const loadingSpinner = document.createElement('div');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
pullIndicator.style.top = `${headerBottom + 30}px`;
pullIndicator.className = 'pull-indicator text-gray-500';
pullIndicator.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 19c-.3 0-.6-.1-.8-.3l-6-6c-.4-.4-.4-1 0-1.4s1-.4 1.4 0L11 16.2V5c0-.6.4-1 1-1s1 .4 1 1v11.2l4.4-4.4c.4-.4 1-.4 1.4 0s.4 1 0 1.4l-6 6c-.2.2-.5.3-.8.3z"/>
</svg>
`;
el.appendChild(pullIndicator);
const parallaxOffset = transform * PARALLAX_EFFECT;
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
loadingSpinner.style.transition = 'opacity 0.15s ease-out';
loadingSpinner.style.opacity = opacity.toString();
loadingSpinner.className = 'pull-refresh-loading';
loadingSpinner.innerHTML = `<span class="loading loading-infinity loading-lg"></span>`;
el.appendChild(loadingSpinner);
}
function removePullIndicator(el: HTMLDivElement) {
const pullIndicator = el.querySelector('.pull-indicator');
if (pullIndicator) {
pullIndicator.remove();
function updateLoadingSpinnerPosition(el: HTMLDivElement, transform: number, dy: number) {
const loadingSpinner = el.querySelector('.pull-refresh-loading') as HTMLElement;
if (!loadingSpinner) return;
const headerbar = document.querySelector('.titlebar');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
const parallaxOffset = transform * PARALLAX_EFFECT;
const opacity = Math.min(1, 0.2 + (dy / TRIGGER_THRESHOLD_STAGE2) * 0.8);
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
loadingSpinner.style.opacity = opacity.toString();
}
function hideLoadingSpinner(el: HTMLDivElement) {
const loadingSpinner = el.querySelector('.pull-refresh-loading');
if (loadingSpinner) {
loadingSpinner.remove();
}
}
function flipArrow(el: HTMLDivElement) {
const pullIndicator = el.querySelector('.pull-indicator');
if (pullIndicator && !pullIndicator.classList.contains('flip')) {
pullIndicator.classList.add('flip');
}
}
function handleTouchEnd(endEvent: TouchEvent) {
async function handleTouchEnd(endEvent: TouchEvent) {
const el = ref.current;
if (!el) return;
const wrapper = el.querySelector('.transform-wrapper') as HTMLElement;
if (wrapper) {
wrapper.style.transform = 'translateY(0)';
}
removePullIndicator(el.parentNode as HTMLDivElement);
el.style.transition = 'transform 0.2s';
const parentEl = el.parentNode as HTMLDivElement;
const y = endEvent.changedTouches[0]!.clientY;
const dy = y - initialY;
if (dy > TRIGGER_THRESHOLD) {
onTrigger();
}
el.addEventListener('transitionend', onTransitionEnd);
el.removeEventListener('touchmove', handleTouchMove);
el.removeEventListener('touchend', handleTouchEnd);
const isStage2 = onTriggerStage2 && dy > TRIGGER_THRESHOLD_STAGE2;
const isStage1 = dy > TRIGGER_THRESHOLD_STAGE1;
if (isStage2 || isStage1) {
isLoading = true;
// Calculate current transform value with damping
const transformValue = appr(dy);
const targetPosition = Math.min(transformValue, MAX_LOADING_POSITION);
if (wrapper) {
wrapper.style.transition = 'transform 0.2s ease-out';
wrapper.style.transform = `translateY(${targetPosition}px)`;
}
const loadingSpinner = parentEl.querySelector('.pull-refresh-loading') as HTMLElement;
if (loadingSpinner) {
const headerbar = document.querySelector('.titlebar');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
const parallaxOffset = targetPosition * PARALLAX_EFFECT;
loadingSpinner.style.transition = 'top 0.2s ease-out';
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
loadingSpinner.style.opacity = '1';
// Remove transition after snap animation completes for smooth touch tracking
setTimeout(() => {
if (loadingSpinner) {
loadingSpinner.style.transition = 'opacity 0.15s ease-out';
}
}, 200);
}
// Add touch listeners during loading to detect pull up and update parallax
let loadingTouchStartY = 0;
const handleLoadingTouchStart = (e: TouchEvent) => {
loadingTouchStartY = e.touches[0]!.clientY;
};
const handleLoadingTouchMove = (e: TouchEvent) => {
const currentY = e.touches[0]!.clientY;
const pullDelta = currentY - loadingTouchStartY;
// Update both wrapper and spinner position to maintain parallax consistency
const newTransform = targetPosition + pullDelta;
if (newTransform > 0) {
if (wrapper) {
wrapper.style.transform = `translateY(${newTransform}px)`;
}
// Update spinner position with same parallax calculation
const loadingSpinner = parentEl.querySelector('.pull-refresh-loading') as HTMLElement;
if (loadingSpinner) {
const headerbar = document.querySelector('.titlebar');
const headerBottom = headerbar?.getBoundingClientRect().bottom || 0;
const parallaxOffset = newTransform * PARALLAX_EFFECT;
loadingSpinner.style.top = `${headerBottom + parallaxOffset}px`;
loadingSpinner.style.opacity = '1';
}
}
// User pulled up significantly, reset
if (pullDelta < -30) {
if (wrapper) {
wrapper.style.transition = 'transform 0.3s ease-out';
wrapper.style.transform = 'translateY(0)';
}
hideLoadingSpinner(parentEl);
el.removeEventListener('touchstart', handleLoadingTouchStart);
el.removeEventListener('touchmove', handleLoadingTouchMove);
}
};
el.addEventListener('touchstart', handleLoadingTouchStart, { passive: true });
el.addEventListener('touchmove', handleLoadingTouchMove, { passive: true });
try {
const triggerFn = isStage2 ? onTriggerStage2 : onTriggerStage1;
await Promise.resolve(triggerFn());
} catch (error) {
console.error('Pull to refresh error:', error);
} finally {
isLoading = false;
hideLoadingSpinner(parentEl);
if (wrapper) {
wrapper.style.transition = 'transform 0.3s ease-out';
wrapper.style.transform = 'translateY(0)';
setTimeout(() => {
if (wrapper) wrapper.style.transition = '';
}, 300);
}
el.removeEventListener('touchstart', handleLoadingTouchStart);
el.removeEventListener('touchmove', handleLoadingTouchMove);
}
} else {
hideLoadingSpinner(parentEl);
if (wrapper) {
wrapper.style.transition = 'transform 0.2s';
wrapper.style.transform = 'translateY(0)';
}
el.addEventListener('transitionend', onTransitionEnd);
}
}
function onTransitionEnd() {
+5 -2
View File
@@ -110,7 +110,7 @@ export function useSync(bookKey?: string) {
const result = await syncClient.pullChanges(since, type, bookId, metaHash);
setSyncResult({ ...syncResult, [type]: result[type] });
const records = result[type];
if (since > 1000 && !records?.length) return;
if (since > 1000 && !records?.length) return 0;
// For since <= 1000, we set lastSyncedAt to now if no records returned
const maxTime = records?.length ? computeMaxTimestamp(records) : Date.now();
setLastSyncedAt(maxTime);
@@ -140,6 +140,7 @@ export function useSync(bookKey?: string) {
}
break;
}
return records?.length || 0;
} catch (err: unknown) {
console.error(err);
if (err instanceof Error) {
@@ -152,6 +153,7 @@ export function useSync(bookKey?: string) {
} else {
setSyncError(`Error pulling ${type}`);
}
return 0;
} finally {
setSyncing(false);
saveSettings(envConfig, settings);
@@ -184,13 +186,14 @@ export function useSync(bookKey?: string) {
await pushChanges({ books });
}
if (op === 'pull' || op === 'both') {
await pullChanges(
return await pullChanges(
'books',
since ?? lastSyncedAtBooks + 1,
setLastSyncedAtBooks,
setSyncingBooks,
);
}
return;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastSyncedAtInited, lastSyncedAtBooks],
+1 -10
View File
@@ -296,7 +296,7 @@ foliate-fxl {
visibility: visible;
}
.pull-indicator {
.pull-refresh-loading {
position: fixed;
width: 100%;
background-color: transparent;
@@ -304,18 +304,9 @@ foliate-fxl {
align-items: center;
justify-content: center;
color: var(--color-text-lighter);
transition: transform 0.2s ease-in-out;
z-index: 10;
}
.pull-indicator svg {
transition: transform 0.2s ease-in-out;
}
.pull-indicator.flip svg {
transform: rotate(180deg);
}
.content {
font-size: 16px;
}