diff --git a/.vscode/settings.json b/.vscode/settings.json index ca232f1..d0e4f7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,7 @@ "Sider", "Syncable", "Transpiler", + "UNSYNC", "Voronoi", "Websockets", "antd", diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx index 2645de0..b7518af 100644 --- a/src/global/AppServices.tsx +++ b/src/global/AppServices.tsx @@ -9,6 +9,7 @@ import { SyncWork } from "./ws/SyncWork"; import { SyncMOTD } from "./ws/SyncMOTD"; import { AppHotkeys } from "./AppHotkeys"; +import { StorageBroadcast } from "./StorageBroadcast"; export function AppServices(): JSX.Element { return <> @@ -18,5 +19,6 @@ + ; } diff --git a/src/global/StorageBroadcast.tsx b/src/global/StorageBroadcast.tsx new file mode 100644 index 0000000..d20fe53 --- /dev/null +++ b/src/global/StorageBroadcast.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import { store } from "@app"; +import * as actions from "@actions/WalletsActions"; + +import { getWalletKey, parseWallet, syncWallet } from "@wallets"; + +import Debug from "debug"; +const debug = Debug("kristweb:storage-broadcast"); + +export const channel = new BroadcastChannel("kristweb:storage"); + +export function broadcastAddWallet(id: string): void { + debug("broadcasting addWallet event for wallet id %s", id); + channel.postMessage(["addWallet", id]); +} + +export function broadcastEditWallet(id: string): void { + debug("broadcasting editWallet event for wallet id %s", id); + channel.postMessage(["editWallet", id]); +} + +export function broadcastDeleteWallet(id: string): void { + debug("broadcasting deleteWallet event for wallet id %s", id); + channel.postMessage(["deleteWallet", id]); +} + +/** Component that manages a BroadcastChannel responsible for dispatching wallet + * storage events (add, edit, delete) across tabs. */ +export function StorageBroadcast(): JSX.Element | null { + // TODO: is it safe to register this here? + debug("registering storage broadcast event listener"); + channel.onmessage = e => { + debug("received storage broadcast:", e); + + if (Array.isArray(e.data)) { + const [type, ...data] = e.data; + + if (type === "addWallet" || type === "editWallet") { + const id: string = data[0]; + const key = getWalletKey(id); + + // Load the wallet from localStorage (the update should've been + // synchronous) + const wallet = parseWallet(id, localStorage.getItem(key)); + debug("%s broadcast %s: %o", type, id, wallet); + + // Dispatch the new/updated wallet to the Redux store + if (type === "addWallet") store.dispatch(actions.addWallet(wallet)); + else store.dispatch(actions.updateWallet(id, wallet)); + + syncWallet(wallet, true); + } else if (type === "deleteWallet") { + const id: string = data[0]; + debug("addWallet broadcast %s", id); + store.dispatch(actions.removeWallet(id)); + } else { + debug("received unknown broadcast msg type %s", type); + } + } + }; + + return null; +} diff --git a/src/krist/wallets/functions/addWallet.ts b/src/krist/wallets/functions/addWallet.ts index 592b236..20c50ec 100644 --- a/src/krist/wallets/functions/addWallet.ts +++ b/src/krist/wallets/functions/addWallet.ts @@ -9,6 +9,7 @@ import { aesGcmEncrypt } from "@utils/crypto"; import { Wallet, WalletNew, saveWallet, syncWallet, calculateAddress } from ".."; +import { broadcastAddWallet } from "@global/StorageBroadcast"; /** * Adds a new wallet, encrypting its privatekey and password, saving it to @@ -52,7 +53,10 @@ }; // Save the wallet to local storage if wanted - if (save) saveWallet(newWallet); + if (save) { + saveWallet(newWallet); + broadcastAddWallet(newWallet.id); // Broadcast changes to other tabs + } // Dispatch the changes to the redux store store.dispatch(actions.addWallet(newWallet)); diff --git a/src/krist/wallets/functions/editWallet.ts b/src/krist/wallets/functions/editWallet.ts index b559e23..f7f25c9 100644 --- a/src/krist/wallets/functions/editWallet.ts +++ b/src/krist/wallets/functions/editWallet.ts @@ -7,6 +7,7 @@ import { aesGcmEncrypt } from "@utils/crypto"; import { Wallet, WalletNew, saveWallet, syncWallet, calculateAddress } from ".."; +import { broadcastEditWallet } from "@global/StorageBroadcast"; /** * Edits a new wallet, encrypting its privatekey and password, saving it to @@ -48,6 +49,7 @@ // Save the updated wallet to local storage saveWallet(finalWallet); + broadcastEditWallet(wallet.id); // Broadcast changes to other tabs // Dispatch the changes to the redux store store.dispatch(actions.updateWallet(wallet.id, finalWallet)); @@ -83,6 +85,7 @@ // Save the updated wallet to local storage saveWallet(finalWallet); + broadcastEditWallet(wallet.id); // Broadcast changes to other tabs // Dispatch the changes to the redux store store.dispatch(actions.updateWallet(wallet.id, finalWallet)); diff --git a/src/krist/wallets/functions/syncWallets.ts b/src/krist/wallets/functions/syncWallets.ts index 7e78207..e5a4238 100644 --- a/src/krist/wallets/functions/syncWallets.ts +++ b/src/krist/wallets/functions/syncWallets.ts @@ -13,21 +13,35 @@ function syncWalletProperties( wallet: Wallet, - address: KristAddressWithNames, + address: KristAddressWithNames | null, syncTime: Date ): Wallet { - return { - ...wallet, - ...(address.balance !== undefined ? { balance: address.balance } : {}), - ...(address.names !== undefined ? { names: address.names } : {}), - ...(address.firstseen !== undefined ? { firstSeen: address.firstseen } : {}), - lastSynced: syncTime.toISOString() - }; + if (address) { + return { + ...wallet, + ...(address.balance !== undefined ? { balance: address.balance } : {}), + ...(address.names !== undefined ? { names: address.names } : {}), + ...(address.firstseen !== undefined ? { firstSeen: address.firstseen } : {}), + lastSynced: syncTime.toISOString() + }; + } else { + // Wallet was unsyncable (address not found), clear its properties + return { + ...wallet, + balance: undefined, + names: undefined, + firstSeen: undefined, + lastSynced: syncTime.toISOString() + }; + } } /** Sync the data for a single wallet from the sync node, save it to local * storage, and dispatch the change to the Redux store. */ -export async function syncWallet(wallet: Wallet): Promise { +export async function syncWallet( + wallet: Wallet, + dontSave?: boolean +): Promise { // Fetch the data from the sync node (e.g. balance) const { address } = wallet; const lookupResults = await lookupAddresses([address], true); @@ -35,25 +49,29 @@ debug("synced individual wallet %s (%s): %o", wallet.id, wallet.address, lookupResults); const kristAddress = lookupResults[address]; - if (!kristAddress) return; // Skip unsyncable wallet - - syncWalletUpdate(wallet, kristAddress); + syncWalletUpdate(wallet, kristAddress, dontSave); } /** Given an already synced wallet, save it to local storage, and dispatch the * change to the Redux store. */ export function syncWalletUpdate( wallet: Wallet, - address: KristAddressWithNames + address: KristAddressWithNames | null, + dontSave?: boolean ): void { const syncTime = new Date(); const updatedWallet = syncWalletProperties(wallet, address, syncTime); - // Save the wallet to local storage (unless dontSave is set) - saveWallet(updatedWallet); + // Save the wallet to local storage, unless this was an external sync action + if (!dontSave) saveWallet(updatedWallet); - // Dispatch the change to the Redux store - store.dispatch(actions.syncWallet(wallet.id, updatedWallet)); + if (address) { + // Wallet synced successfully, dispatch the change to the Redux store + store.dispatch(actions.syncWallet(wallet.id, updatedWallet)); + } else { + // Wallet failed to sync, clear its properties + store.dispatch(actions.unsyncWallet(wallet.id, syncTime)); + } } /** Sync the data for all the wallets from the sync node, save it to local diff --git a/src/krist/wallets/walletStorage.ts b/src/krist/wallets/walletStorage.ts index 90af2fe..104208b 100644 --- a/src/krist/wallets/walletStorage.ts +++ b/src/krist/wallets/walletStorage.ts @@ -7,6 +7,10 @@ import { TranslatedError } from "@utils/i18n"; import { Wallet, WalletMap } from "."; +import { broadcastDeleteWallet } from "@global/StorageBroadcast"; + +import Debug from "debug"; +const debug = Debug("kristweb:wallet-storage"); /** The limit provided by the Krist server for a single address lookup. In the * future I may implement batching for these, but for now, this seems like a @@ -15,8 +19,9 @@ export const ADDRESS_LIST_LIMIT = 128; /** Get the local storage key for a given wallet. */ -export function getWalletKey(wallet: Wallet): string { - return `wallet2-${wallet.id}`; +export function getWalletKey(wallet: Wallet | string): string { + const id = typeof wallet === "string" ? wallet : wallet.id; + return `wallet2-${id}`; } /** Extract a wallet ID from a local storage key. */ @@ -26,7 +31,7 @@ return id ? [key, id] : undefined; } -function loadWallet(id: string, data: string | null) { +export function parseWallet(id: string, data: string | null): Wallet { if (data === null) // localStorage key was missing throw new TranslatedError("masterPassword.walletStorageCorrupt"); @@ -54,7 +59,7 @@ .map(extractWalletKey) .filter(k => k !== undefined) as [string, string][]; - const wallets = keysToLoad.map(([key, id]) => loadWallet(id, localStorage.getItem(key))); + const wallets = keysToLoad.map(([key, id]) => parseWallet(id, localStorage.getItem(key))); // Convert to map with wallet IDs const walletMap: WalletMap = wallets.reduce((obj, w) => ({ ...obj, [w.id]: w }), {}); @@ -67,6 +72,8 @@ if (wallet.dontSave) return; const key = getWalletKey(wallet); + debug("saving wallet key %s with data %o", key, wallet); + const serialised = JSON.stringify(wallet); localStorage.setItem(key, serialised); } @@ -77,5 +84,7 @@ const key = getWalletKey(wallet); localStorage.removeItem(key); + broadcastDeleteWallet(wallet.id); // Broadcast changes to other tabs + store.dispatch(actions.removeWallet(wallet.id)); } diff --git a/src/store/actions/WalletsActions.ts b/src/store/actions/WalletsActions.ts index f4f006b..3bad1f0 100644 --- a/src/store/actions/WalletsActions.ts +++ b/src/store/actions/WalletsActions.ts @@ -31,6 +31,10 @@ export const syncWallets = createAction(constants.SYNC_WALLETS, (wallets): SyncWalletsPayload => ({ wallets }))(); +export interface UnsyncWalletPayload { id: string; lastSynced: string } +export const unsyncWallet = createAction(constants.UNSYNC_WALLET, + (id, lastSynced): UnsyncWalletPayload => ({ id, lastSynced }))(); + export interface RecalculateWalletsPayload { wallets: Record } export const recalculateWallets = createAction(constants.RECALCULATE_WALLETS, (wallets): RecalculateWalletsPayload => ({ wallets }))(); diff --git a/src/store/constants.ts b/src/store/constants.ts index cf5a11c..6a7ed4a 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -15,6 +15,7 @@ export const UPDATE_WALLET = "UPDATE_WALLET"; export const SYNC_WALLET = "SYNC_WALLET"; export const SYNC_WALLETS = "SYNC_WALLETS"; +export const UNSYNC_WALLET = "UNSYNC_WALLET"; export const RECALCULATE_WALLETS = "RECALCULATE_WALLETS"; // Settings diff --git a/src/store/reducers/WalletsReducer.ts b/src/store/reducers/WalletsReducer.ts index 3eef56e..4d9d537 100644 --- a/src/store/reducers/WalletsReducer.ts +++ b/src/store/reducers/WalletsReducer.ts @@ -35,7 +35,7 @@ export const WalletsReducer = createReducer({ wallets: {} } as State) // Load wallets - .handleAction(actions.loadWallets, (state: State, { payload }: ActionType) => ({ + .handleAction(actions.loadWallets, (state, { payload }) => ({ ...state, wallets: { ...state.wallets, @@ -43,7 +43,7 @@ } })) // Add wallet - .handleAction(actions.addWallet, (state: State, { payload }: ActionType) => ({ + .handleAction(actions.addWallet, (state, { payload }) => ({ ...state, wallets: { ...state.wallets, @@ -51,19 +51,19 @@ } })) // Remove wallet - .handleAction(actions.removeWallet, (state: State, { payload }: ActionType) => { + .handleAction(actions.removeWallet, (state, { payload }) => { // Get the wallets without the one we want to remove const { [payload.id]: _, ...wallets } = state.wallets; return { ...state, wallets }; }) // Update wallet - .handleAction(actions.updateWallet, (state: State, { payload }: ActionType) => + .handleAction(actions.updateWallet, (state, { payload }) => assignNewWalletProperties(state, payload.id, payload.wallet, WALLET_UPDATABLE_KEYS)) // Sync wallet - .handleAction(actions.syncWallet, (state: State, { payload }: ActionType) => + .handleAction(actions.syncWallet, (state, { payload }) => assignNewWalletProperties(state, payload.id, payload.wallet, WALLET_SYNCABLE_KEYS)) // Sync wallets - .handleAction(actions.syncWallets, (state: State, { payload }: ActionType) => { + .handleAction(actions.syncWallets, (state, { payload }) => { const updatedWallets = Object.entries(payload.wallets) .map(([id, newData]) => ({ // merge in the new data ...(state.wallets[id]), // old data @@ -78,8 +78,22 @@ return { ...state, wallets: { ...state.wallets, ...updatedWallets }}; }) + // Unsync wallet (remove its balance etc. as it no longer exists) + .handleAction(actions.unsyncWallet, (state, { payload }) => ({ + ...state, + wallets: { + ...state.wallets, + [payload.id]: { + ...state.wallets[payload.id], + balance: undefined, + names: undefined, + firstSeen: undefined, + lastSynced: payload.lastSynced + } + } + })) // Recalculate wallets - .handleAction(actions.recalculateWallets, (state: State, { payload }: ActionType) => { + .handleAction(actions.recalculateWallets, (state, { payload }) => { const updatedWallets = Object.entries(payload.wallets) .map(([id, newData]) => ({ // merge in the new data ...(state.wallets[id]), // old data diff --git a/tsconfig.extend.json b/tsconfig.extend.json index 4bd0469..5ac721f 100644 --- a/tsconfig.extend.json +++ b/tsconfig.extend.json @@ -18,6 +18,7 @@ "@wallets": ["./krist/wallets"], "@wallets/*": ["./krist/wallets/*"], "@krist/*": ["./krist/*"], + "@global/*": ["./global/*"], "@utils": ["./utils"], "@utils/*": ["./utils/*"]