From 6b305770e07d3984fa18ed48b72e8c641251f52a Mon Sep 17 00:00:00 2001 From: thekingofcity <3353040+thekingofcity@users.noreply.github.com> Date: Sun, 5 Jan 2025 15:50:37 +0800 Subject: [PATCH] #151 Enhanced sync strategy (#156) --- .../menu/account-view/account-status.tsx | 62 +------ .../account-view/forgot-password-view.tsx | 2 +- .../menu/account-view/register-view.tsx | 2 +- .../menu/account-view/saves-section.tsx | 80 ++++----- src/components/menu/nav-menu.tsx | 3 + .../modal/resolve-conflict-modal.tsx | 154 ++++++++++++++++++ src/i18n/translations/ja.json | 40 +++-- src/i18n/translations/ko.json | 10 +- src/i18n/translations/zh-Hans.json | 12 +- src/i18n/translations/zh-Hant.json | 10 +- src/index.tsx | 9 +- src/redux/account/account-slice.ts | 75 ++++++++- src/redux/index.ts | 2 + src/redux/init.ts | 33 +++- src/redux/rmp-save/rmp-save-slice.ts | 58 +++++++ src/util/constants.ts | 4 +- src/util/download.ts | 17 ++ src/util/local-storage-save.ts | 74 ++++++--- src/util/utils.ts | 2 + 19 files changed, 490 insertions(+), 159 deletions(-) create mode 100644 src/components/modal/resolve-conflict-modal.tsx create mode 100644 src/redux/rmp-save/rmp-save-slice.ts create mode 100644 src/util/download.ts diff --git a/src/components/menu/account-view/account-status.tsx b/src/components/menu/account-view/account-status.tsx index f738d32..96032db 100644 --- a/src/components/menu/account-view/account-status.tsx +++ b/src/components/menu/account-view/account-status.tsx @@ -1,77 +1,21 @@ -import { Avatar, Button, Text, useToast } from '@chakra-ui/react'; -import { logger } from '@railmapgen/rmg-runtime'; +import { Avatar, Button, Text } from '@chakra-ui/react'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { MdArrowForwardIos } from 'react-icons/md'; import { useRootDispatch, useRootSelector } from '../../../redux'; -import { fetchSaveList, setToken } from '../../../redux/account/account-slice'; +import { fetchSaveList } from '../../../redux/account/account-slice'; import { setMenuView } from '../../../redux/app/app-slice'; -import { API_ENDPOINT, SAVE_KEY } from '../../../util/constants'; -import { getRMPSave, notifyRMPSaveChange, setRMPSave } from '../../../util/local-storage-save'; -import { apiFetch } from '../../../util/utils'; const AccountStatus = () => { - const toast = useToast(); const { t } = useTranslation(); const dispatch = useRootDispatch(); - const { isLoggedIn, name, token, refreshToken, saves, currentSaveId } = useRootSelector(state => state.account); - - const showErrorToast = (msg: string) => - toast({ - title: msg, - status: 'error' as const, - duration: 9000, - isClosable: true, - }); + const { isLoggedIn, name } = useRootSelector(state => state.account); React.useEffect(() => { if (!isLoggedIn) return; dispatch(fetchSaveList()); }, [isLoggedIn]); - // Below: Set RMP save with cloud latest on logged in - - React.useEffect(() => { - if (!currentSaveId) return; - checkIfRMPSaveNeedsUpdated(); - }, [currentSaveId]); - - const checkIfRMPSaveNeedsUpdated = async () => { - const save = await getRMPSave(SAVE_KEY.RMP); - if (!save) { - setRMPSaveWithCloudLatest(); - return; - } - const { hash: localHash } = save; - const cloudHash = saves.filter(save => save.id === currentSaveId).at(0)?.hash; - if (!cloudHash) return; - if (cloudHash !== localHash) { - setRMPSaveWithCloudLatest(); - } - }; - - const setRMPSaveWithCloudLatest = async () => { - const { - rep, - token: updatedToken, - refreshToken: updatedRefreshToken, - } = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, {}, token, refreshToken); - if (!updatedRefreshToken || !updatedToken) { - showErrorToast(t('Login status expired')); - return; - } - dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken })); - if (rep.status !== 200) { - showErrorToast(await rep.text()); - return; - } - logger.info(`Set ${SAVE_KEY.RMP} with save id: ${currentSaveId}`); - setRMPSave(SAVE_KEY.RMP, await rep.text()); - notifyRMPSaveChange(); - }; - - // Above: Set RMP save with cloud latest on logged in - return ( + + + + + + + + + + {t('Cloud save')} + + + + {t('Update at:')} {new Date(lastUpdatedAtTimeStamp).toLocaleString()} + + + + + + + + + + + + + + + + ); +}; + +export default ResolveConflictModal; diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index bb9562f..a2c98e7 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -1,9 +1,9 @@ { "CookiesModal": { - "header": "本ウェブサイトのクッキー", - "text1": "RMGでは、ウェブサイトおよびサービスが正常に動作するようにするため、クッキーを使用しています。これらのクッキーは必須であり、デフォルトで有効化されています。", + "header": "本網頁のクッキー", + "text1": "RMGでは、網頁およびサービスが正常に動作するようにするため、クッキーを使用しています。これらのクッキーは必須であり、デフォルトで有効化されています。", "text2": "また、クッキーを使用して以下を行います:", - "item1": "利用パターンを分析し、ウェブサイトの改善に役立てる", + "item1": "利用パターンを分析し、網頁の改善に役立てる", "text3": "これらのクッキーは任意です。すべての任意クッキーを許可したい場合は「すべてのクッキーを受け入れる」を選択してください。クッキーを無効にしたい場合は「拒否」を選択してください。", "accept": "すべてのクッキーを受け入れる", "reject": "拒否" @@ -17,7 +17,7 @@ }, "About": "概要", - "Allow cookies to help improve our website": "クッキーを許可してウェブサイトの改善に協力する", + "Allow cookies to help improve our website": "クッキーを許可して網頁の改善に協力する", "Appearance": "外観", "Back to production environment": "本番環境に戻る", "Close all tabs": "すべてのタブを閉じる", @@ -35,11 +35,11 @@ "Don't show again": "再度表示しない", "Download desktop app": "デスクトップアプリをダウンロード", "Fonts are slow to load? Learn how to speed it up!": "フォントの読み込みが遅い?スピードアップの方法を学びましょう!", - "Official website": "公式ウェブサイト", + "Official website": "公式網頁", "GitHub Pages mirror": "GitHub Pages ミラー", "GitLab Pages mirror": "GitLab Pages ミラー", "Happy Chinese New Year!": "新年あけましておめでとうございます!", - "Help & support": "ヘルプ&サポート", + "Help & support": "助けと支援", "Join us on Slack": "Slackに参加する", "Light": "ライト", "Main languages": "主要言語", @@ -66,7 +66,7 @@ "Tab": "タブ", "Terms and conditions": "利用規約", "Tutorial": "チュートリアル", - "Official Website": "公式サイト", + "Official Website": "公式網頁", "Mini Metro Web": "ミニ地下鉄描画ツール", "Unable to load contributors": "貢献者を読み込めません", "Useful links": "便利なリンク", @@ -102,28 +102,36 @@ "You may always change it later.": "後でいつでも変更できます。", "Send verification code": "確認コードを送信", "Verification code": "確認コード", - "Failed to get the RMP save!": "RMPセーブを取得できませんでした!", - "Can not sync this save!": "このセーブを同期できません!", - "Synced saves": "同期されたセーブ", + "Failed to get the RMP save!": "RMP保存を取得できませんでした!", + "Can not sync this save!": "この保存を同期できません!", + "Synced saves": "同期された保存", "Maximum save count:": "最大保存数:", "Create": "作成", - "Current save": "現在のセーブ", - "Cloud save": "クラウドセーブ", + "Current save": "現在の保存", + "Cloud save": "クラウド保存", "Last update at:": "最終更新日:", - "Delete this save": "このセーブを削除", + "Delete this save": "この保存を削除", "Sync now": "今すぐ同期", "Sync this slot": "このスロットを同期", "All subscriptions": "すべてのサブスクリプション", "Redeem": "引き換え", "With this subscription, the following features are unlocked:": "このサブスクリプションで次の機能がアンロックされます:", "PRO features": "PRO機能", - "Sync 9 more saves": "追加の9つのセーブを同期", + "Sync 9 more saves": "追加の9つの保存を同期", "Unlimited master nodes": "大師節点無制限", "Unlimited parallel lines": "平行路線無制限", "Expires at:": "期限:", "Not applicable": "該当なし", "Renew": "更新", "Redeem your subscription": "サブスクリプションを引き換え", - "CDKey could be purchased in the following sites:": "CD鍵は以下のサイトで購入できます:", - "Enter your CDKey here:": "ここにCD鍵を入力:" + "CDKey could be purchased in the following sites:": "CD鍵は以下の網頁で購入できます:", + "Enter your CDKey here:": "ここにCD鍵を入力:", + + "Oops! It seems there's a conflict": "おっと!競合が発生したようです", + "The local save is newer than the cloud one. Which one would you like to keep?": "本地の保存が雲の保存より新しいです。どちらを保持しますか?", + "Local save": "本地保存", + "Replace cloud with local": "雲を本地で置き換える", + "Download Local save": "本地保存をダウンロード", + "Replace local with cloud": "本地を雲で置き換える", + "Download Cloud save": "雲保存をダウンロード" } diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index bd3ddaf..7085f5f 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -125,5 +125,13 @@ "Renew": "갱신", "Redeem your subscription": "구독을 교환하세요", "CDKey could be purchased in the following sites:": "CDKey는 다음 사이트에서 구매할 수 있습니다:", - "Enter your CDKey here:": "여기에 CDKey를 입력하세요:" + "Enter your CDKey here:": "여기에 CDKey를 입력하세요:", + + "Oops! It seems there's a conflict": "이런! 충돌이 발생한 것 같습니다", + "The local save is newer than the cloud one. Which one would you like to keep?": "로컬 저장이 클라우드 저장보다 최신입니다. 어느 것을 유지하시겠습니까?", + "Local save": "로컬 저장", + "Replace cloud with local": "클라우드를 로컬로 교체", + "Download Local save": "로컬 저장 다운로드", + "Replace local with cloud": "로컬을 클라우드로 교체", + "Download Cloud save": "클라우드 저장 다운로드" } diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index 5e657ad..4235c4c 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -105,7 +105,7 @@ "Send verification code":"发送验证码", "Verification code sent": "验证码已发送", "Verification code":"验证码", - "Mininum 8 characters. Contain at least 1 letter and 1 number.": "8个字符以上,包含至少1个字母和1个数字。", + "Minimum 8 characters. Contain at least 1 letter and 1 number.": "8个字符以上,包含至少1个字母和1个数字。", "Failed to get the RMP save!":"无法获取RMP存档", "Can not sync this save!":"无法同步此存档", "Synced saves":"同步的存档", @@ -129,5 +129,13 @@ "Renew":"续期", "Redeem your subscription":"兑换您的订阅", "CDKey could be purchased in the following sites:":"兑换码可在以下网站获取:", - "Enter your CDKey here:":"输入您的兑换码:" + "Enter your CDKey here:":"输入您的兑换码:", + + "Oops! It seems there's a conflict": "哎呀!好像有冲突", + "The local save is newer than the cloud one. Which one would you like to keep?": "本地存档比云端的新。您想保留哪个版本?", + "Local save": "本地存档", + "Replace cloud with local": "用本地存档替换云端", + "Download Local save": "下载本地存档", + "Replace local with cloud": "用云端存档替换本地", + "Download Cloud save": "下载云端存档" } diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 49c543d..a67d65f 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -129,5 +129,13 @@ "Renew": "續期", "Redeem your subscription": "兌換您的訂閱", "CDKey could be purchased in the following sites:": "CDKey可在以下網站購買:", - "Enter your CDKey here:": "在此輸入您的CDKey:" + "Enter your CDKey here:": "在此輸入您的CDKey:", + + "Oops! It seems there's a conflict": "哎呀!好像有衝突", + "The local save is newer than the cloud one. Which one would you like to keep?": "本地存檔比雲端的新。您想保留哪個版本?", + "Local save": "本地存檔", + "Replace cloud with local": "用本地存檔替換雲端", + "Download Local save": "下載本地存檔", + "Replace local with cloud": "用雲端存檔替換本地", + "Download Cloud save": "下載雲端存檔" } diff --git a/src/index.tsx b/src/index.tsx index a38c668..9ce466c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,6 +20,7 @@ import initStore from './redux/init'; import { getAllowedAssetTypes, getAvailableAsset } from './util/asset-enablements'; import { Events, FRAME_ID_PREFIX } from './util/constants'; import { registerOnRMPSaveChange, registerOnTokenRequest } from './util/local-storage-save'; +import { fetchSaveList, syncAfterLogin } from './redux/account/account-slice'; let root: Root; const AppRoot = lazy(() => import('./components/app-root')); @@ -41,8 +42,14 @@ const renderApp = () => { ); }; -rmgRuntime.ready().then(() => { +rmgRuntime.ready().then(async () => { initStore(store); + + // If user is logged in previously, fetch and sync the save (resolve conflicts might be needed). + // Otherwise this is a no-op. + await store.dispatch(fetchSaveList()); + await store.dispatch(syncAfterLogin()); + renderApp(); rmgRuntime.onAppOpen(app => { diff --git a/src/redux/account/account-slice.ts b/src/redux/account/account-slice.ts index 7d47538..226cef9 100644 --- a/src/redux/account/account-slice.ts +++ b/src/redux/account/account-slice.ts @@ -1,8 +1,11 @@ +import { logger } from '@railmapgen/rmg-runtime'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import i18n from '../../i18n/config'; import { RootState } from '../../redux/index'; -import { API_ENDPOINT, API_URL, APILoginResponse, APISaveInfo, APISaveList } from '../../util/constants'; -import { notifyRMPTokenUpdate } from '../../util/local-storage-save'; +import { API_ENDPOINT, API_URL, APILoginResponse, APISaveInfo, APISaveList, SAVE_KEY } from '../../util/constants'; +import { getRMPSave, notifyRMPSaveChange, notifyRMPTokenUpdate, setRMPSave } from '../../util/local-storage-save'; import { apiFetch } from '../../util/utils'; +import { setLastChangedAtTimeStamp, setResolveConflictModal } from '../rmp-save/rmp-save-slice'; export interface ActiveSubscriptions { RMP_CLOUD: boolean; @@ -55,8 +58,8 @@ export interface LoginInfo { export const fetchSaveList = createAsyncThunk( 'account/getSaveList', async (_, { getState, dispatch, rejectWithValue }) => { - const { token, refreshToken } = (getState() as RootState).account; - if (!token) rejectWithValue('No token.'); + const { isLoggedIn, token, refreshToken } = (getState() as RootState).account; + if (!isLoggedIn || !token) return rejectWithValue('No token.'); const { rep, token: updatedToken, @@ -64,11 +67,11 @@ export const fetchSaveList = createAsyncThunk( } = await apiFetch(API_ENDPOINT.SAVES, {}, token, refreshToken); if (!updatedToken || !updatedRefreshToken) { dispatch(logout()); - rejectWithValue('Can not recover from expired refresh token.'); + return rejectWithValue('Can not recover from expired refresh token.'); } dispatch(setToken({ access: updatedToken!, refresh: updatedRefreshToken! })); if (rep.status !== 200) { - rejectWithValue(rep.text); + return rejectWithValue(rep.text); } return (await rep.json()) as APISaveList; } @@ -97,12 +100,70 @@ export const fetchLogin = createAsyncThunk<{ error?: string; username?: string } }, } = (await loginRes.json()) as APILoginResponse; dispatch(login({ id: userId, name: username, email, token, expires, refreshToken, refreshExpires })); - dispatch(fetchSaveList()); + await dispatch(fetchSaveList()); // make sure saves are set before syncAfterLogin notifyRMPTokenUpdate(token); + + await dispatch(syncAfterLogin()); + return { error: undefined, username }; } ); +/** + * Fetch the cloud save and see which one is newer. + * If the cloud save is newer, update the local save with the cloud save. + * If the local save is newer, prompt the user to choose between local and cloud. + */ +export const syncAfterLogin = createAsyncThunk( + 'account/syncAfterLogin', + async (_, { getState, dispatch, rejectWithValue }) => { + logger.debug('Sync after login - check if local save is newer'); + const state = getState() as RootState; + const { + account: { isLoggedIn, token, refreshToken, currentSaveId, saves }, + rmpSave: { lastChangedAtTimeStamp }, + } = state; + const lastChangedAt = new Date(lastChangedAtTimeStamp); + const save = saves.filter(save => save.id === currentSaveId).at(0); + if (!isLoggedIn || !save) { + // TODO: ask sever to reconstruct currentSaveId + return rejectWithValue(`Save id: ${currentSaveId} is not in saveList!`); + } + const lastUpdateAt = new Date(save.lastUpdateAt); + const { + rep, + token: updatedToken, + refreshToken: updatedRefreshToken, + } = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, {}, token, refreshToken); + if (!updatedRefreshToken || !updatedToken) { + dispatch(logout()); + return rejectWithValue(i18n.t('Login status expired.')); + } + dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken })); + if (rep.status !== 200) { + return rejectWithValue(await rep.text()); + } + const cloudData = await rep.text(); + const localData = await getRMPSave(SAVE_KEY.RMP); + if (lastChangedAt <= lastUpdateAt || !localData) { + // update newer cloud to local (lastChangedAt <= saves[currentSaveId].lastUpdateAt) + logger.info(`Set ${SAVE_KEY.RMP} with save id: ${currentSaveId}`); + setRMPSave(SAVE_KEY.RMP, cloudData); + dispatch(setLastChangedAtTimeStamp(new Date().valueOf())); + notifyRMPSaveChange(); + } else { + // prompt user to choose between local and cloud (lastChangedAt > saves[currentSaveId].lastUpdateAt) + dispatch( + setResolveConflictModal({ + lastChangedAtTimeStamp: lastChangedAt.valueOf(), + lastUpdatedAtTimeStamp: lastUpdateAt.valueOf(), + cloudData: cloudData, + }) + ); + } + } +); + const accountSlice = createSlice({ name: 'account', initialState, diff --git a/src/redux/index.ts b/src/redux/index.ts index 5a1a00e..a232bbb 100644 --- a/src/redux/index.ts +++ b/src/redux/index.ts @@ -2,10 +2,12 @@ import { combineReducers, configureStore, createListenerMiddleware, TypedStartLi import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import accountReducer from './account/account-slice'; import appReducer from './app/app-slice'; +import rmpSaveReducer from './rmp-save/rmp-save-slice'; const rootReducer = combineReducers({ app: appReducer, account: accountReducer, + rmpSave: rmpSaveReducer, }); export type RootState = ReturnType; diff --git a/src/redux/init.ts b/src/redux/init.ts index 5aa6cd2..13169d9 100644 --- a/src/redux/init.ts +++ b/src/redux/init.ts @@ -15,6 +15,7 @@ import { showDevtools, } from './app/app-slice'; import { RootStore, startRootListening } from './index'; +import { RMPSaveState, setLastChangedAtTimeStamp } from './rmp-save/rmp-save-slice'; export const initShowDevtools = (store: RootStore) => { const lastShowDevTools = Number(rmgRuntime.storage.get(LocalStorageKey.LAST_SHOW_DEVTOOLS)); @@ -81,12 +82,12 @@ export const openSearchedApp = (store: RootStore) => { } }; -export const initAccount = (store: RootStore) => { +export const initAccountStore = (store: RootStore) => { const accountString = window.localStorage.getItem(LocalStorageKey.ACCOUNT); if (accountString) { const accountData = JSON.parse(accountString) as LoginInfo; - logger.debug(`Get account data from local storage: ${accountData}`); + logger.debug(`Get account data from local storage: ${JSON.stringify(accountData)}`); store.dispatch(login(accountData)); } @@ -120,11 +121,27 @@ export const initAccount = (store: RootStore) => { }, intervalMS); }; +export const initRMPSaveStore = (store: RootStore) => { + const rmpSaveString = window.localStorage.getItem(LocalStorageKey.RMP_SAVE); + + if (rmpSaveString) { + const rmpSaveData = JSON.parse(rmpSaveString) as Pick; + logger.debug(`Get RMP save data from local storage: ${JSON.stringify(rmpSaveData)}`); + store.dispatch(setLastChangedAtTimeStamp(rmpSaveData.lastChangedAtTimeStamp)); + } else { + // Default to 0 on fresh start and will be overwritten on login. + // (cloud lastUpdateAt must be greater than lastChangedAt(0)) + logger.warn('No RMP save data from local storage. Setting lastChangedAtTimeStamp to 0.'); + store.dispatch(setLastChangedAtTimeStamp(0)); + } +}; + export default function initStore(store: RootStore) { initShowDevtools(store); initOpenedTabs(store); initActiveTab(store); - initAccount(store); + initAccountStore(store); + initRMPSaveStore(store); if (isSafari() || rmgRuntime.storage.get(LocalStorageKey.SHOW_FONT_ADVICE) === 'never') { store.dispatch(neverShowFontAdvice()); @@ -180,6 +197,16 @@ export default function initStore(store: RootStore) { }, }); + startRootListening({ + predicate: (_action, currentState, previousState) => { + return currentState.rmpSave.lastChangedAtTimeStamp !== previousState.rmpSave.lastChangedAtTimeStamp; + }, + effect: (_action, listenerApi) => { + const { lastChangedAtTimeStamp } = listenerApi.getState().rmpSave; + window.localStorage.setItem(LocalStorageKey.RMP_SAVE, JSON.stringify({ lastChangedAtTimeStamp })); + }, + }); + openSearchedApp(store); checkInstance().then(isPrimary => { diff --git a/src/redux/rmp-save/rmp-save-slice.ts b/src/redux/rmp-save/rmp-save-slice.ts new file mode 100644 index 0000000..4bdf212 --- /dev/null +++ b/src/redux/rmp-save/rmp-save-slice.ts @@ -0,0 +1,58 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface RMPSaveState { + /** + * The last time the user made a change to the save. + * Will be compared with the lastUpdateAt from the cloud to determine if there is a conflict on login. + * Will be set to now on every save. + */ + lastChangedAtTimeStamp: number; + resolveConflictModal: { + isOpen: boolean; + lastChangedAtTimeStamp: number; + lastUpdatedAtTimeStamp: number; + cloudData: string; + }; +} + +const initialState: RMPSaveState = { + lastChangedAtTimeStamp: 0, + resolveConflictModal: { + isOpen: false, + lastChangedAtTimeStamp: 0, + lastUpdatedAtTimeStamp: 0, + cloudData: '', + }, +}; + +const rmpSaveSlice = createSlice({ + name: 'save', + initialState, + reducers: { + setLastChangedAtTimeStamp: (state, action: PayloadAction) => { + state.lastChangedAtTimeStamp = action.payload; + }, + setResolveConflictModal: ( + state, + action: PayloadAction<{ lastChangedAtTimeStamp: number; lastUpdatedAtTimeStamp: number; cloudData: string }> + ) => { + state.resolveConflictModal = { + isOpen: true, + lastChangedAtTimeStamp: action.payload.lastChangedAtTimeStamp, + lastUpdatedAtTimeStamp: action.payload.lastUpdatedAtTimeStamp, + cloudData: action.payload.cloudData, + }; + }, + clearResolveConflictModal: state => { + state.resolveConflictModal = { + isOpen: false, + lastChangedAtTimeStamp: 0, + lastUpdatedAtTimeStamp: 0, + cloudData: '', + }; + }, + }, +}); + +export const { setLastChangedAtTimeStamp, setResolveConflictModal, clearResolveConflictModal } = rmpSaveSlice.actions; +export default rmpSaveSlice.reducer; diff --git a/src/util/constants.ts b/src/util/constants.ts index 105fc7c..c16cc3f 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -12,6 +12,7 @@ export enum LocalStorageKey { LAST_SHOW_DEVTOOLS = 'lastShowDevtools', SHOW_FONT_ADVICE = 'showFontAdvice', ACCOUNT = 'rmg-home__account', + RMP_SAVE = 'rmg-home__rmp-save', } export enum Events { @@ -52,6 +53,7 @@ export enum API_ENDPOINT { } export const API_URL = 'https://railmapgen.org/v1'; +// export const API_URL = 'http://localhost:3000/v1'; export interface APILoginResponse { user: { id: number; name: string }; @@ -62,7 +64,7 @@ export interface APISaveInfo { index: string; id: number; hash: string; - lastUpdateAt: Date; + lastUpdateAt: string; } export interface APISaveList { diff --git a/src/util/download.ts b/src/util/download.ts new file mode 100644 index 0000000..0ddb102 --- /dev/null +++ b/src/util/download.ts @@ -0,0 +1,17 @@ +export const downloadAs = (filename: string, type: string, data: BlobPart) => { + const blob = new Blob([data], { type }); + downloadBlobAs(filename, blob); +}; + +export const downloadBlobAs = (filename: string, blob: Blob) => { + const url = window.URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + + document.body.removeChild(a); + window.URL.revokeObjectURL(url); +}; diff --git a/src/util/local-storage-save.ts b/src/util/local-storage-save.ts index 1c54a64..847b4cf 100644 --- a/src/util/local-storage-save.ts +++ b/src/util/local-storage-save.ts @@ -1,6 +1,7 @@ import { logger } from '@railmapgen/rmg-runtime'; -import { fetchSaveList, logout, setToken } from '../redux/account/account-slice'; +import { fetchSaveList, logout, setToken, syncAfterLogin } from '../redux/account/account-slice'; import { createStore } from '../redux/index'; +import { setLastChangedAtTimeStamp } from '../redux/rmp-save/rmp-save-slice'; import { API_ENDPOINT, SAVE_KEY } from './constants'; import { apiFetch, createHash } from './utils'; @@ -45,43 +46,74 @@ const SAVE_UPDATE_TIMEOUT_MS = 60 * 1000; // 1min export const registerOnRMPSaveChange = (store: ReturnType) => { const eventHandler = async (ev: MessageEvent) => { + const { type, key, from } = ev.data; + if (type === SaveManagerEventType.SAVE_CHANGED && from === 'rmp') { + logger.info(`Received save changed event on key: ${key}`); + store.dispatch(setLastChangedAtTimeStamp(new Date().valueOf())); + } + if (updateSaveTimeout) { return; } updateSaveTimeout = window.setTimeout(async () => { + updateSaveTimeout = undefined; + const { isLoggedIn, currentSaveId, token, refreshToken } = store.getState().account; if (!isLoggedIn || !currentSaveId || !token || !refreshToken) return; const { type, key, from } = ev.data; if (type === SaveManagerEventType.SAVE_CHANGED && from === 'rmp') { - logger.info(`Received save changed event on key: ${key}`); - if (isLoggedIn && currentSaveId) { - logger.info(`Update remote save id: ${currentSaveId} with local key: ${key}`); - // TODO: updating save won't have a isLoading button effect on the save being synced - const { - rep, - token: updatedToken, - refreshToken: updatedRefreshToken, - } = await onSaveUpdate(currentSaveId, token, refreshToken, key!); - if (!updatedRefreshToken || !updatedToken) { - store.dispatch(logout()); - return; - } - store.dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken })); - if (rep.status !== 200) return; - store.dispatch(fetchSaveList()); + logger.info(`Update save after timeout on key: ${key}`); + + if (!isLoggedIn || !currentSaveId) { + logger.warn('Not logged in or no current save id. No save update.'); + return; } - } - updateSaveTimeout = undefined; + const { saves: saveList } = store.getState().account; + const save = saveList.filter(save => save.id === currentSaveId).at(0); + if (!save) { + logger.error(`Save id: ${currentSaveId} is not in saveList!`); + // TODO: ask sever to reconstruct currentSaveId + return; + } + + const lastUpdateAt = new Date(save.lastUpdateAt); + const { lastChangedAtTimeStamp } = store.getState().rmpSave; + const lastChangedAt = new Date(lastChangedAtTimeStamp); + if (lastChangedAt < lastUpdateAt) { + logger.warn(`Save id: ${currentSaveId} is newer in the cloud via local compare.`); + store.dispatch(syncAfterLogin()); + return; + } + + logger.info(`Update remote save id: ${currentSaveId} with local key: ${key}`); + const { + rep, + token: updatedToken, + refreshToken: updatedRefreshToken, + } = await updateSave(currentSaveId, token, refreshToken, key!); + if (!updatedRefreshToken || !updatedToken) { + store.dispatch(logout()); + return; + } + store.dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken })); + if (rep.status === 409) { + logger.warn(`Save id: ${currentSaveId} is newer in the cloud via server response.`); + store.dispatch(syncAfterLogin()); + return; + } + if (rep.status !== 200) return; + store.dispatch(fetchSaveList()); + } }, SAVE_UPDATE_TIMEOUT_MS); }; channel.addEventListener('message', eventHandler); }; -const onSaveUpdate = async (currentSaveId: number, token: string, refreshToken: string, key: SAVE_KEY) => { +export const updateSave = async (currentSaveId: number, token: string, refreshToken: string, key: SAVE_KEY) => { const save = await getRMPSave(key); if (!save) return { rep: undefined, token: undefined, refreshToken: undefined }; const { data, hash } = save; @@ -96,7 +128,7 @@ const onSaveUpdate = async (currentSaveId: number, token: string, refreshToken: ); }; -// This should triggger RMP to refetch subscription info. +// This should trigger RMP to refetch subscription info. export const notifyRMPTokenUpdate = (token?: string) => { channel.postMessage({ type: SaveManagerEventType.TOKEN_REQUEST, diff --git a/src/util/utils.ts b/src/util/utils.ts index f6ea50d..98116e0 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -30,9 +30,11 @@ export const apiFetch = async ( const defaultHeaders = { accept: 'application/json', 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', } as { accept: string; 'Content-Type': string; + 'Cache-Control': string; Authorization?: string; }; const headers = structuredClone(defaultHeaders);