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/*"]