diff --git a/src/LoginGate.tsx b/src/LoginGate.tsx index f98e6a0..02e5e58 100644 --- a/src/LoginGate.tsx +++ b/src/LoginGate.tsx @@ -16,6 +16,7 @@ import * as C from "./constants"; import SignedPagesBadge from "./images/signed-pages-badge.svg"; import { useCredentials } from "./credentials"; +import LoadingIndicator from "./widgets/LoadingIndicator"; export default function LoginGate() { @@ -35,9 +36,11 @@ export default function LoginGate() { } } - const loading = credentials === undefined; - - if (!credentials) { + if (credentials === undefined) { + return ( + + ); + } else if (credentials === null) { const style = { isSafe: { textDecoration: "none", @@ -55,7 +58,6 @@ export default function LoginGate() {
@@ -75,6 +77,6 @@ export default function LoginGate() { } return ( - + ); } diff --git a/src/SyncGate.tsx b/src/SyncGate.tsx index b6a62c8..17f0b3e 100644 --- a/src/SyncGate.tsx +++ b/src/SyncGate.tsx @@ -2,14 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from "react"; -import { useSelector } from "react-redux"; -import { Route, Switch, Redirect, RouteComponentProps, withRouter } from "react-router"; +import { useSelector, useDispatch } from "react-redux"; +import { Route, Switch, Redirect, withRouter, useHistory } from "react-router"; import moment from "moment"; import "moment/locale/en-gb"; import { List, Map } from "immutable"; -import { createSelector } from "reselect"; import { routeResolver } from "./App"; @@ -22,10 +21,12 @@ import Debug from "./Debug"; import Pim from "./Pim"; import * as EteSync from "etesync"; -import { CURRENT_VERSION } from "etesync"; -import { store, JournalsData, EntriesData, StoreState, CredentialsData, UserInfoData } from "./store"; -import { addJournal, fetchAll, fetchEntries, fetchUserInfo, createUserInfo } from "./store/actions"; +import { SyncManager } from "./sync/SyncManager"; + +import { StoreState } from "./store"; +import { performSync } from "./store/actions"; +import { useCredentials } from "./credentials"; export interface SyncInfoJournal { journal: EteSync.Journal; @@ -36,157 +37,37 @@ export interface SyncInfoJournal { export type SyncInfo = Map; -interface PropsType { - etesync: CredentialsData; -} - -interface SelectorProps { - etesync: CredentialsData; - journals: JournalsData; - entries: EntriesData; - userInfo: UserInfoData; -} - -const syncInfoSelector = createSelector( - (props: SelectorProps) => props.etesync, - (props: SelectorProps) => props.journals!, - (props: SelectorProps) => props.entries, - (props: SelectorProps) => props.userInfo, - (etesync, journals, entries, userInfo) => { - const derived = etesync.encryptionKey; - const userInfoCryptoManager = userInfo.getCryptoManager(etesync.encryptionKey); - try { - userInfo.verify(userInfoCryptoManager); - } catch (error) { - if (error instanceof EteSync.IntegrityError) { - throw new EteSync.EncryptionPasswordError(error.message); - } else { - throw error; - } - } - - return journals.reduce( - (ret, journal) => { - const journalEntries = entries.get(journal.uid); - let prevUid: string | null = null; - - if (!journalEntries) { - return ret; - } - - const keyPair = userInfo.getKeyPair(userInfoCryptoManager); - const cryptoManager = journal.getCryptoManager(derived, keyPair); - - const collectionInfo = journal.getInfo(cryptoManager); - - const syncEntries = journalEntries.map((entry: EteSync.Entry) => { - const syncEntry = entry.getSyncEntry(cryptoManager, prevUid); - prevUid = entry.uid; - - return syncEntry; - }); - - return ret.set(journal.uid, { - entries: syncEntries, - collection: collectionInfo, - journal, - journalEntries, - }); - }, - Map() - ); - } -); - const PimRouter = withRouter(Pim); -// FIXME: this and withRouters are only needed here because of https://github.com/ReactTraining/react-router/issues/5795 -export default withRouter(function SyncGate(props: RouteComponentProps<{}> & PropsType) { - const etesync = props.etesync; +export default function SyncGate() { + const etebase = useCredentials(); const settings = useSelector((state: StoreState) => state.settings); - const fetchCount = useSelector((state: StoreState) => state.fetchCount); const userInfo = useSelector((state: StoreState) => state.cache.userInfo); const journals = useSelector((state: StoreState) => state.cache.journals); - const entries = useSelector((state: StoreState) => state.cache.entries); + const dispatch = useDispatch(); + const [loading, setLoading] = React.useState(true); + + // Doing this so we refresh on route changes + useHistory(); React.useEffect(() => { - const me = etesync.credentials.email; - const syncAll = () => { - store.dispatch(fetchAll(etesync, entries)).then((haveJournals: boolean) => { - if (haveJournals) { - return; - } - - [ - { - type: "ADDRESS_BOOK", - name: "My Contacts", - }, - { - type: "CALENDAR", - name: "My Calendar", - }, - { - type: "TASKS", - name: "My Tasks", - }, - ].forEach((collectionDesc) => { - const collection = new EteSync.CollectionInfo(); - collection.uid = EteSync.genUid(); - collection.type = collectionDesc.type; - collection.displayName = collectionDesc.name; - - const journal = new EteSync.Journal({ uid: collection.uid }); - const cryptoManager = new EteSync.CryptoManager(etesync.encryptionKey, collection.uid); - journal.setInfo(cryptoManager, collection); - (async () => { - try { - const addedJournalAction = addJournal(etesync, journal); - await addedJournalAction.payload; - store.dispatch(addedJournalAction); - store.dispatch(fetchEntries(etesync, collection.uid)); - } catch (e) { - // FIXME: Limit based on error code to only ignore for associates - console.warn(`Failed creating journal for ${collection.type}. Associate?`); - } - })(); - }); - }); - }; - - if (userInfo) { - syncAll(); - } else { - const fetching = fetchUserInfo(etesync, me); - fetching.payload?.then(() => { - store.dispatch(fetching); - syncAll(); - }).catch(() => { - const userInfo = new EteSync.UserInfo(me, CURRENT_VERSION); - const keyPair = EteSync.AsymmetricCryptoManager.generateKeyPair(); - const cryptoManager = userInfo.getCryptoManager(etesync.encryptionKey); - - userInfo.setKeyPair(cryptoManager, keyPair); - - store.dispatch(createUserInfo(etesync, userInfo)).then(syncAll); - }); - } + (async () => { + const syncManager = SyncManager.getManager(etebase!); + const sync = syncManager.sync(); + dispatch(performSync(sync)); + await sync; + setLoading(false); + })(); }, []); - const entryArrays = entries; - - if ((userInfo === null) || (journals === null) || - ((fetchCount > 0) && - ((entryArrays.size === 0) || entryArrays.some((x) => (x.size === 0)))) - ) { + if (loading) { return (); } // FIXME: Shouldn't be here moment.locale(settings.locale); - - const journalMap = syncInfoSelector({ etesync, userInfo, journals, entries }); + const etesync = etebase as any; return ( @@ -197,33 +78,37 @@ export default withRouter(function SyncGate(props: RouteComponentProps<{}> & Pro )} /> - ( - <> - - - - )} - /> - ( - + ( + <> + + + + )} /> - )} - /> + ( + + )} + /> + + )} & Pro /> ); -}); +} diff --git a/src/sync/SyncManager.ts b/src/sync/SyncManager.ts new file mode 100644 index 0000000..e13e296 --- /dev/null +++ b/src/sync/SyncManager.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: © 2019 EteSync Authors +// SPDX-License-Identifier: GPL-3.0-only + +import * as Etebase from "etebase"; + +import { store, persistor, StoreState } from "../store"; + +import { credentialsSelector } from "../credentials"; +import { setSyncCollection, setSyncGeneral, setCacheItem, setCacheCollection, unsetCacheCollection, unsetCacheItem } from "../store/actions"; + +const cachedSyncManager = new Map(); +export class SyncManager { + private COLLECTION_TYPES = ["etebase.vcard", "etebase.vevent", "etebase.vtodo"]; + private BATCH_SIZE = 40; + + public static getManager(etebase: Etebase.Account) { + const cached = cachedSyncManager.get(etebase.user.username); + if (cached) { + return cached; + } + + const ret = new SyncManager(); + cachedSyncManager.set(etebase.user.username, ret); + return ret; + } + + public static removeManager(etebase: Etebase.Account) { + cachedSyncManager.delete(etebase.user.username); + } + + protected etebase: Etebase.Account; + protected isSyncing: boolean; + + public async fetchCollection(col: Etebase.Collection) { + const storeState = store.getState() as unknown as StoreState; + const etebase = (await credentialsSelector(storeState))!; + const syncCollection = storeState.sync.collections.get(col.uid, undefined); + + const colMgr = etebase.getCollectionManager(); + const itemMgr = colMgr.getItemManager(col); + + let stoken = syncCollection?.stoken; + const limit = this.BATCH_SIZE; + let done = false; + while (!done) { + const items = await itemMgr.list({ stoken, limit }); + for (const item of items.data) { + if (item.isDeleted) { + store.dispatch(unsetCacheItem(col.uid, itemMgr, item.uid)); + } else { + store.dispatch(setCacheItem(col, itemMgr, item)); + } + } + done = items.done; + stoken = items.stoken; + } + + if (syncCollection?.stoken !== stoken) { + store.dispatch(setSyncCollection(col.uid, stoken!)); + } + } + + public async fetchAllCollections() { + const storeState = store.getState() as unknown as StoreState; + const etebase = (await credentialsSelector(storeState))!; + const syncGeneral = storeState.sync.general; + + const colMgr = etebase.getCollectionManager(); + const limit = this.BATCH_SIZE; + let stoken = syncGeneral?.stoken; + let done = false; + while (!done) { + const collections = await colMgr.list({ stoken, limit }); + for (const col of collections.data) { + const meta = await col.getMeta(); + if (this.COLLECTION_TYPES.includes(meta.type)) { + // We only get the changed collections here, so always fetch + if (col.isDeleted) { + store.dispatch(unsetCacheCollection(colMgr, col.uid)); + } else { + store.dispatch(setCacheCollection(colMgr, col)); + } + await this.fetchCollection(col); + } + } + if (collections.removedMemberships) { + for (const removed of collections.removedMemberships) { + store.dispatch(unsetCacheCollection(colMgr, removed.uid)); + } + } + done = collections.done; + stoken = collections.stoken; + } + + if (syncGeneral?.stoken !== stoken) { + store.dispatch(setSyncGeneral(stoken)); + } + return true; + } + + public async sync() { + if (this.isSyncing) { + return false; + } + this.isSyncing = true; + + try { + const stoken = await this.fetchAllCollections(); + return stoken; + } catch (e) { + if (e instanceof Etebase.NetworkError) { + // Ignore network errors + return null; + } else if (e instanceof Etebase.HTTPError) { + switch (e.status) { + case 401: // INVALID TOKEN + case 403: // FORBIDDEN + case 503: // UNAVAILABLE + // FIXME store.dispatch(addNonFatalError(this.etebase, e)); + return null; + } + } + throw e; + } finally { + this.isSyncing = false; + + // Force flusing the store to disk + persistor.persist(); + } + } +}