{/* Page header */}
diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx
new file mode 100644
index 0000000..149bccd
--- /dev/null
+++ b/src/layout/nav/AppHeader.tsx
@@ -0,0 +1,65 @@
+import React from "react";
+import { Layout, Menu, AutoComplete, Input, Grid } from "antd";
+import { SendOutlined, DownloadOutlined, MenuOutlined, SettingOutlined } from "@ant-design/icons";
+
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+
+import { Brand } from "./Brand";
+import { ConnectionIndicator } from "./ConnectionIndicator";
+import { CymbalIndicator } from "./CymbalIndicator";
+
+const { useBreakpoint } = Grid;
+
+interface Props {
+ sidebarCollapsed: boolean;
+ setSidebarCollapsed: React.Dispatch
>;
+}
+
+export function AppHeader({ sidebarCollapsed, setSidebarCollapsed }: Props): JSX.Element {
+ const { t } = useTranslation();
+ const bps = useBreakpoint();
+
+ return
+ {/* Sidebar toggle for mobile */}
+ {!bps.md && (
+
+ )}
+
+ {/* Logo */}
+ {bps.md && }
+
+ {/* Send and receive buttons */}
+ {bps.md && }
+
+ {/* Spacer to push search box to the right */}
+ {bps.md && }
+
+ {/* Search box */}
+
+
+ {/* Connection indicator */}
+
+
+ {/* Cymbal indicator */}
+
+
+ {/* Settings button */}
+
+ ;
+}
diff --git a/src/layout/nav/ConnectionIndicator.less b/src/layout/nav/ConnectionIndicator.less
new file mode 100644
index 0000000..466b0dc
--- /dev/null
+++ b/src/layout/nav/ConnectionIndicator.less
@@ -0,0 +1,28 @@
+@import (reference) "../../App.less";
+
+.connection-indicator {
+ vertical-align: middle;
+ line-height: @layout-header-height;
+
+ &::after {
+ content: " ";
+ display: inline-block;
+
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+
+ background-color: @kw-green;
+ box-shadow: 0 0 0 3px rgba(@kw-green, 0.3);
+ }
+
+ &.connection-connecting::after {
+ background-color: @kw-secondary;
+ box-shadow: 0 0 0 3px rgba(@kw-secondary, 0.3);
+ }
+
+ &.connection-disconnected::after {
+ background-color: @kw-red;
+ box-shadow: 0 0 0 3px rgba(@kw-red, 0.3);
+ }
+}
diff --git a/src/layout/nav/ConnectionIndicator.tsx b/src/layout/nav/ConnectionIndicator.tsx
new file mode 100644
index 0000000..5f5d521
--- /dev/null
+++ b/src/layout/nav/ConnectionIndicator.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { Tooltip } from "antd";
+
+import { useSelector } from "react-redux";
+import { RootState } from "../../store";
+import { useTranslation } from "react-i18next";
+
+import { WSConnectionState } from "../../krist/api/types";
+
+import "./ConnectionIndicator.less";
+
+const CONN_STATE_TOOLTIPS: Record = {
+ "connected": "nav.connection.online",
+ "disconnected": "nav.connection.offline",
+ "connecting": "nav.connection.connecting"
+};
+
+export function ConnectionIndicator(): JSX.Element {
+ const { t } = useTranslation();
+ const connectionState = useSelector((s: RootState) => s.websocket.connectionState);
+
+ return ;
+}
diff --git a/src/layout/sidebar/SidebarFooter.tsx b/src/layout/sidebar/SidebarFooter.tsx
index b0ad641..8710ab6 100644
--- a/src/layout/sidebar/SidebarFooter.tsx
+++ b/src/layout/sidebar/SidebarFooter.tsx
@@ -1,17 +1,16 @@
-import React, { useState, useEffect } from "react";
+import React, { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import packageJson from "../../../package.json";
import { Link } from "react-router-dom";
+import { useMountEffect } from "../../utils";
+
export function SidebarFooter(): JSX.Element {
const { t } = useTranslation();
- const [host, setHost] = useState<{ name: string; url: string } | false | undefined>();
+ const [host, setHost] = useState<{ name: string; url: string } | undefined>();
- useEffect(() => {
- if (host !== undefined) return;
- setHost(false);
-
+ useMountEffect(() => {
(async () => {
try {
// Add the host information if host.json exists
@@ -22,7 +21,7 @@
// Ignored
}
})();
- }, [host]);
+ });
const authorName = packageJson.author || "Lemmmy";
const authorURL = `https://github.com/${authorName}`;
diff --git a/src/pages/credits/Supporters.tsx b/src/pages/credits/Supporters.tsx
index 043596b..af91137 100644
--- a/src/pages/credits/Supporters.tsx
+++ b/src/pages/credits/Supporters.tsx
@@ -1,10 +1,12 @@
-import React, { useState, useEffect } from "react";
+import React, { useState } from "react";
import { Space, Spin, Button } from "antd";
import { DollarOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import packageJson from "../../../package.json";
+import { useMountEffect } from "../../utils";
+
interface Supporter {
name: string;
url?: string;
@@ -19,16 +21,12 @@
const { supportURL, supportersURL } = packageJson;
const { t } = useTranslation();
- const [fetched, setFetched] = useState(false);
const [supportersState, setSupportersState] = useState({
loaded: false,
supporters: undefined
});
- useEffect(() => {
- if (fetched) return;
- setFetched(true);
-
+ useMountEffect(() => {
(async () => {
// GPU required for this function:
const res = await fetch(supportersURL);
@@ -39,7 +37,7 @@
supporters: data.supporters
});
})();
- }, [fetched, supportersURL]);
+ });
if (!supportURL) return null;
return
diff --git a/src/pages/settings/SettingsTranslations.tsx b/src/pages/settings/SettingsTranslations.tsx
index 7136bd4..512ef25 100644
--- a/src/pages/settings/SettingsTranslations.tsx
+++ b/src/pages/settings/SettingsTranslations.tsx
@@ -1,9 +1,10 @@
-import React, { useState, useEffect } from "react";
+import React, { useState } from "react";
import { Table, Progress, Result, Typography, Tooltip, Button } from "antd";
import { ExclamationCircleOutlined, FileExcelOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { getLanguages, Language } from "../../utils/i18n";
+import { useMountEffect } from "../../utils";
import csvStringify from "csv-stringify";
import { saveAs } from "file-saver";
@@ -96,7 +97,6 @@
export function SettingsTranslations(): JSX.Element {
const { t } = useTranslation();
- const [fetched, setFetched] = useState(false);
const [loading, setLoading] = useState(true);
const [analysed, setAnalysed] = useState<{
enKeyCount: number;
@@ -148,11 +148,7 @@
saveAs(blob, "kristweb-translations.csv");
}
- useEffect(() => {
- if (fetched) return;
- setFetched(true);
- loadLanguages();
- }, [fetched]);
+ useMountEffect(() => { loadLanguages().catch(console.error); });
return } onClick={exportCSV}>
diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx
index 7c86e43..5f39d8d 100644
--- a/src/pages/wallets/AddWalletModal.tsx
+++ b/src/pages/wallets/AddWalletModal.tsx
@@ -68,12 +68,22 @@
try {
if (editing) { // Edit wallet
- // Double check the wallet exists
+ // Double check the destination wallet exists
if (!wallets[editing.id]) return notification.error({
message: t("addWallet.errorMissingWalletTitle"),
description: t("addWallet.errorMissingWalletDescription")
});
+ // If the address changed, check that a wallet doesn't already exist
+ // with this address
+ if (editing.address !== calculatedAddress
+ && Object.values(wallets).find(w => w.address === calculatedAddress)) {
+ return notification.error({
+ message: t("addWallet.errorDuplicateWalletTitle"),
+ description: t("addWallet.errorDuplicateWalletDescription")
+ });
+ }
+
await editWallet(dispatch, masterPassword, editing, values, values.password);
message.success(t("addWallet.messageSuccessEdit"));
diff --git a/src/store/actions/Settings.ts b/src/store/actions/Settings.ts
deleted file mode 100644
index 97e5898..0000000
--- a/src/store/actions/Settings.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PickByValue } from "utility-types";
-import { createAction } from "typesafe-actions";
-
-import * as constants from "../constants";
-
-import { State } from "../reducers/SettingsReducer";
-
-export interface SetBooleanSettingPayload {
- settingName: keyof PickByValue;
- value: boolean;
-}
-export const setBooleanSetting = createAction(constants.SET_BOOLEAN_SETTING,
- (settingName, value): SetBooleanSettingPayload =>
- ({ settingName, value }))();
diff --git a/src/store/actions/SettingsActions.ts b/src/store/actions/SettingsActions.ts
new file mode 100644
index 0000000..97e5898
--- /dev/null
+++ b/src/store/actions/SettingsActions.ts
@@ -0,0 +1,14 @@
+import { PickByValue } from "utility-types";
+import { createAction } from "typesafe-actions";
+
+import * as constants from "../constants";
+
+import { State } from "../reducers/SettingsReducer";
+
+export interface SetBooleanSettingPayload {
+ settingName: keyof PickByValue;
+ value: boolean;
+}
+export const setBooleanSetting = createAction(constants.SET_BOOLEAN_SETTING,
+ (settingName, value): SetBooleanSettingPayload =>
+ ({ settingName, value }))();
diff --git a/src/store/actions/WebsocketActions.ts b/src/store/actions/WebsocketActions.ts
new file mode 100644
index 0000000..b697367
--- /dev/null
+++ b/src/store/actions/WebsocketActions.ts
@@ -0,0 +1,6 @@
+import { createAction } from "typesafe-actions";
+import { WSConnectionState } from "../../krist/api/types";
+
+import * as constants from "../constants";
+
+export const setConnectionState = createAction(constants.CONNECTION_STATE)();
diff --git a/src/store/actions/index.ts b/src/store/actions/index.ts
index 8f3aab8..16d89e2 100644
--- a/src/store/actions/index.ts
+++ b/src/store/actions/index.ts
@@ -1,10 +1,12 @@
import * as walletManagerActions from "./WalletManagerActions";
import * as walletsActions from "./WalletsActions";
-import * as settingsActions from "./Settings";
+import * as settingsActions from "./SettingsActions";
+import * as websocketActions from "./WebsocketActions";
const RootAction = {
walletManager: walletManagerActions,
wallets: walletsActions,
settings: settingsActions,
+ websocket: websocketActions
};
export default RootAction;
diff --git a/src/store/constants.ts b/src/store/constants.ts
index 3cac3b6..59b31e5 100644
--- a/src/store/constants.ts
+++ b/src/store/constants.ts
@@ -15,3 +15,7 @@
// Settings
// ---
export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING";
+
+// Websockets
+// ---
+export const CONNECTION_STATE = "CONNECTION_STATE";
diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts
index 6a2e4be..3d7ecfd 100644
--- a/src/store/reducers/RootReducer.ts
+++ b/src/store/reducers/RootReducer.ts
@@ -3,9 +3,11 @@
import { WalletManagerReducer } from "./WalletManagerReducer";
import { WalletsReducer } from "./WalletsReducer";
import { SettingsReducer } from "./SettingsReducer";
+import { WebsocketReducer } from "./WebsocketReducer";
export default combineReducers({
walletManager: WalletManagerReducer,
wallets: WalletsReducer,
settings: SettingsReducer,
+ websocket: WebsocketReducer
});
diff --git a/src/store/reducers/SettingsReducer.ts b/src/store/reducers/SettingsReducer.ts
index a888d3f..b148d3a 100644
--- a/src/store/reducers/SettingsReducer.ts
+++ b/src/store/reducers/SettingsReducer.ts
@@ -1,6 +1,6 @@
import { createReducer, ActionType } from "typesafe-actions";
import { loadSettings, SettingsState } from "../../utils/settings";
-import { setBooleanSetting } from "../actions/Settings";
+import { setBooleanSetting } from "../actions/SettingsActions";
export type State = SettingsState;
diff --git a/src/store/reducers/WebsocketReducer.ts b/src/store/reducers/WebsocketReducer.ts
new file mode 100644
index 0000000..a002037
--- /dev/null
+++ b/src/store/reducers/WebsocketReducer.ts
@@ -0,0 +1,17 @@
+import { createReducer, ActionType } from "typesafe-actions";
+import { WSConnectionState } from "../../krist/api/types";
+import { setConnectionState } from "../actions/WebsocketActions";
+
+export interface State {
+ readonly connectionState: WSConnectionState;
+}
+
+export const initialState: State = {
+ connectionState: "disconnected"
+};
+
+export const WebsocketReducer = createReducer(initialState)
+ .handleAction(setConnectionState, (state: State, action: ActionType) => ({
+ ...state,
+ connectionState: action.payload
+ }));
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 95834cc..9d20cad 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,3 +1,5 @@
+import { EffectCallback, useEffect } from "react";
+
export const toHex = (input: ArrayBufferLike | Uint8Array): string =>
[...(input instanceof Uint8Array ? input : new Uint8Array(input))]
.map(b => b.toString(16).padStart(2, "0"))
@@ -79,3 +81,5 @@
: (va as any) - (vb as any);
}
};
+
+export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []);
diff --git a/src/utils/settings.ts b/src/utils/settings.ts
index b43fd94..3965c38 100644
--- a/src/utils/settings.ts
+++ b/src/utils/settings.ts
@@ -1,7 +1,7 @@
import { PickByValue } from "utility-types";
import { AppDispatch } from "../App";
-import * as actions from "../store/actions/Settings";
+import * as actions from "../store/actions/SettingsActions";
export interface SettingsState {
/** Whether or not advanced wallet formats are enabled. */
diff --git a/src/utils/setup.ts b/src/utils/setup.ts
new file mode 100644
index 0000000..e2e45ca
--- /dev/null
+++ b/src/utils/setup.ts
@@ -0,0 +1,8 @@
+import { toHex } from "./";
+import Debug from "debug";
+
+// Set up custom debug formatters
+// Booleans (%b)
+Debug.formatters.b = (v: boolean) => v ? "true" : "false";
+// Buffers as hex strings (%x)
+Debug.formatters.x = (v: ArrayBufferLike | Uint8Array) => toHex(v);