diff --git a/package.json b/package.json index ab3f270..d028bff 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,8 @@ "react-refresh": "^0.9.0", "react-scripts": "4.0.2", "redux-devtools-extension": "^2.13.8", - "typescript": "^4.1.5" + "typescript": "^4.1.5", + "utility-types": "^3.10.0" }, "stylelint": { "extends": "stylelint-config-recommended", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59d9ed9..8913fc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,7 @@ react-scripts: 4.0.2_react@17.0.1+typescript@4.1.5 redux-devtools-extension: 2.13.8_redux@4.0.5 typescript: 4.1.5 + utility-types: 3.10.0 lockfileVersion: 5.2 packages: /@ant-design/colors/6.0.0: @@ -12561,6 +12562,12 @@ dev: true resolution: integrity: sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= + /utility-types/3.10.0: + dev: true + engines: + node: '>= 4' + resolution: + integrity: sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== /utils-merge/1.0.1: dev: true engines: @@ -13249,4 +13256,5 @@ spu-md5: 0.0.4 typesafe-actions: ^5.1.0 typescript: ^4.1.5 + utility-types: ^3.10.0 web-vitals: ^1.1.0 diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0022610..6f18bce 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -147,6 +147,7 @@ "walletFormatKristWallet": "KristWallet, KWallet (recommended)", "walletFormatKristWalletUsernameAppendhashes": "KW-Username (appendhashes)", "walletFormatKristWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", "walletFormatApi": "Raw/API (advanced users)", "walletSave": "Save this wallet in KristWeb" @@ -170,6 +171,7 @@ "menuLanguage": "Language", "subMenuDebug": "Debug settings", + "advancedWalletFormats": "Advanced wallet formats", "menuTranslations": "Translations", "subTitleTranslations": "Translations", diff --git a/src/App.tsx b/src/App.tsx index a37440a..485f100 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,9 @@ import { Provider } from "react-redux"; import { devToolsEnhancer } from "redux-devtools-extension"; import rootReducer from "./store/reducers/RootReducer"; + import { getInitialWalletManagerState } from "./store/reducers/WalletManagerReducer"; +import { getInitialSettingsState } from "./store/reducers/SettingsReducer"; // Set up localisation import "./utils/i18n"; @@ -17,7 +19,8 @@ export const store = createStore( rootReducer, { - walletManager: getInitialWalletManagerState() + walletManager: getInitialWalletManagerState(), + settings: getInitialSettingsState() }, devToolsEnhancer({}) ); diff --git a/src/krist/wallets/formats/WalletFormat.ts b/src/krist/wallets/formats/WalletFormat.ts index d9c2389..3c0cc10 100644 --- a/src/krist/wallets/formats/WalletFormat.ts +++ b/src/krist/wallets/formats/WalletFormat.ts @@ -20,6 +20,9 @@ "api": async password => password }; +export const ADVANCED_FORMATS: WalletFormatName[] = [ + "kristwallet_username_appendhashes", "kristwallet_username", "jwalelset" +]; export const applyWalletFormat = (format: WalletFormatName, password: string, username?: string): Promise => diff --git a/src/layout/AppLayout.less b/src/layout/AppLayout.less index 010133b..5e60b05 100644 --- a/src/layout/AppLayout.less +++ b/src/layout/AppLayout.less @@ -145,6 +145,22 @@ } } + .site-header-element { + flex: 0; + height: 50px; + + display: flex; + align-items: center; + justify-content: center; + + margin-right: 16px; + + .site-header-cymbal { + color: @text-color-secondary; + font-size: 20px; + } + } + .site-header-settings { border-left: 1px solid @kw-border-color-darker; diff --git a/src/layout/AppLayout.tsx b/src/layout/AppLayout.tsx index 33a4bd5..3277578 100644 --- a/src/layout/AppLayout.tsx +++ b/src/layout/AppLayout.tsx @@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { Brand } from "./Brand"; +import { Brand } from "./nav/Brand"; +import { CymbalIndicator } from "./nav/CymbalIndicator"; import { Sidebar } from "./sidebar/Sidebar"; import { AppRouter } from "./AppRouter"; @@ -48,6 +49,9 @@ + {/* Cymbal indicator */} + + {/* Settings button */} } title={t("nav.settings")}> diff --git a/src/layout/Brand.tsx b/src/layout/Brand.tsx deleted file mode 100644 index 52b4c0b..0000000 --- a/src/layout/Brand.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from "react"; - -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -import semverMajor from "semver/functions/major"; -import semverMinor from "semver/functions/minor"; -import semverPatch from "semver/functions/patch"; -import semverPrerelease from "semver/functions/prerelease"; - -import { Tag } from "antd"; - -import packageJson from "../../package.json"; - -const prereleaseTagColours: { [key: string]: string } = { - "dev": "red", - "alpha": "orange", - "beta": "blue", - "rc": "green" -}; - -export function Brand(): JSX.Element { - const { t } = useTranslation(); - - const version = packageJson.version; - - const major = semverMajor(version); - const minor = semverMinor(version); - const patch = semverPatch(version); - const prerelease = semverPrerelease(version); - - // Convert semver prerelease parts to Bootstrap badge - let tag = null; - if (prerelease && prerelease.length) { - const variant = prereleaseTagColours[prerelease[0]] || undefined; - tag = {prerelease.join(".")}; - } - - return
- - {t("app.name")} - v{major}.{minor}.{patch} - {tag} - -
; -} diff --git a/src/layout/nav/Brand.tsx b/src/layout/nav/Brand.tsx new file mode 100644 index 0000000..88b3689 --- /dev/null +++ b/src/layout/nav/Brand.tsx @@ -0,0 +1,46 @@ +import React from "react"; + +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import semverMajor from "semver/functions/major"; +import semverMinor from "semver/functions/minor"; +import semverPatch from "semver/functions/patch"; +import semverPrerelease from "semver/functions/prerelease"; + +import { Tag } from "antd"; + +import packageJson from "../../../package.json"; + +const prereleaseTagColours: { [key: string]: string } = { + "dev": "red", + "alpha": "orange", + "beta": "blue", + "rc": "green" +}; + +export function Brand(): JSX.Element { + const { t } = useTranslation(); + + const version = packageJson.version; + + const major = semverMajor(version); + const minor = semverMinor(version); + const patch = semverPatch(version); + const prerelease = semverPrerelease(version); + + // Convert semver prerelease parts to Bootstrap badge + let tag = null; + if (prerelease && prerelease.length) { + const variant = prereleaseTagColours[prerelease[0]] || undefined; + tag = {prerelease.join(".")}; + } + + return
+ + {t("app.name")} + v{major}.{minor}.{patch} + {tag} + +
; +} diff --git a/src/layout/nav/CymbalIndicator.tsx b/src/layout/nav/CymbalIndicator.tsx new file mode 100644 index 0000000..82e356d --- /dev/null +++ b/src/layout/nav/CymbalIndicator.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import Icon from "@ant-design/icons"; + +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "../../store"; +import { SettingsState } from "../../utils/settings"; + +export const CymbalIconSvg = (): JSX.Element => ( + + + +); +export const CymbalIcon = (props: any): JSX.Element => + ; + +export function CymbalIndicator(): JSX.Element | null { + const allSettings: SettingsState = useSelector((s: RootState) => s.settings, shallowEqual); + const on = allSettings.walletFormats; + + return on ?
+ +
: null; +} diff --git a/src/pages/settings/SettingBoolean.tsx b/src/pages/settings/SettingBoolean.tsx new file mode 100644 index 0000000..6ca718f --- /dev/null +++ b/src/pages/settings/SettingBoolean.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Switch } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "../../store"; + +import { SettingName, setBooleanSetting } from "../../utils/settings"; + +interface Props { + setting: SettingName; + title?: string; + titleKey?: string; +} + +export function SettingBoolean({ setting, title, titleKey }: Props): JSX.Element { + const settingValue = useSelector((s: RootState) => s.settings[setting]); + const dispatch = useDispatch(); + + const { t } = useTranslation(); + + function onChange(value: boolean) { + setBooleanSetting(dispatch, setting, value); + } + + return
onChange(!settingValue)} + > + + {titleKey ? t(titleKey) : title} +
; +} diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index 2774db0..801f6f5 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -6,6 +6,7 @@ import { Link } from "react-router-dom"; import { PageLayout, PageLayoutProps } from "../../layout/PageLayout"; +import { SettingBoolean } from "./SettingBoolean"; interface SettingsPageLayoutProps extends PageLayoutProps { pageName?: string; @@ -29,6 +30,10 @@ }>{t("settings.menuLanguage")} } title={t("settings.subMenuDebug")}> + + + + {t("settings.menuTranslations")} diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx index cf1f20b..882dfd5 100644 --- a/src/pages/wallets/AddWalletModal.tsx +++ b/src/pages/wallets/AddWalletModal.tsx @@ -9,7 +9,9 @@ import { FakeUsernameInput } from "../../components/auth/FakeUsernameInput"; import { CopyInputButton } from "../../components/CopyInputButton"; import { getWalletCategoryDropdown } from "../../components/wallets/WalletCategoryDropdown"; + import { WalletFormatName, applyWalletFormat, formatNeedsUsername } from "../../krist/wallets/formats/WalletFormat"; +import { getSelectWalletFormat } from "./SelectWalletFormat"; import { makeV2Address } from "../../krist/AddressAlgo"; const { Text } = Typography; @@ -33,11 +35,13 @@ } export function AddWalletModal({ create, visible, setVisible }: Props): JSX.Element { + const initialFormat = "kristwallet"; // TODO: change for edit modal + const { t } = useTranslation(); const [form] = Form.useForm(); const passwordInput = useRef(null); const [calculatedAddress, setCalculatedAddress] = useState(); - const [formatState, setFormatState] = useState("kristwallet"); + const [formatState, setFormatState] = useState(initialFormat); async function onSubmit() { const values = await form.validateFields(); @@ -92,7 +96,7 @@ initialValues={{ category: "", - format: "kristwallet", + format: initialFormat, save: true }} @@ -179,12 +183,7 @@ {/* Wallet format */} - + {getSelectWalletFormat({ initialFormat })} {/* Save in KristWeb checkbox */} diff --git a/src/pages/wallets/SelectWalletFormat.tsx b/src/pages/wallets/SelectWalletFormat.tsx new file mode 100644 index 0000000..1a9a97b --- /dev/null +++ b/src/pages/wallets/SelectWalletFormat.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Select } from "antd"; + +import { useSelector } from "react-redux"; +import { RootState } from "../../store"; + +import { useTranslation } from "react-i18next"; +import { SettingsState } from "../../utils/settings"; + +import { WalletFormatName, ADVANCED_FORMATS } from "../../krist/wallets/formats/WalletFormat"; + +interface Props { + initialFormat: WalletFormatName; +} + +export function getSelectWalletFormat({ initialFormat }: Props): JSX.Element { + const advancedWalletFormats = useSelector((s: RootState) => (s.settings as SettingsState).walletFormats); + const { t } = useTranslation(); + + return ; +} diff --git a/src/store/actions/Settings.ts b/src/store/actions/Settings.ts new file mode 100644 index 0000000..97e5898 --- /dev/null +++ b/src/store/actions/Settings.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/index.ts b/src/store/actions/index.ts index 6c390ea..8f3aab8 100644 --- a/src/store/actions/index.ts +++ b/src/store/actions/index.ts @@ -1,8 +1,10 @@ import * as walletManagerActions from "./WalletManagerActions"; import * as walletsActions from "./WalletsActions"; +import * as settingsActions from "./Settings"; const RootAction = { walletManager: walletManagerActions, wallets: walletsActions, + settings: settingsActions, }; export default RootAction; diff --git a/src/store/constants.ts b/src/store/constants.ts index d8a0fc3..d814e49 100644 --- a/src/store/constants.ts +++ b/src/store/constants.ts @@ -10,3 +10,7 @@ export const REMOVE_WALLET = "REMOVE_WALLET"; export const UPDATE_WALLET = "UPDATE_WALLET"; export const SYNC_WALLET = "SYNC_WALLET"; + +// Settings +// --- +export const SET_BOOLEAN_SETTING = "SET_BOOLEAN_SETTING"; diff --git a/src/store/reducers/RootReducer.ts b/src/store/reducers/RootReducer.ts index ca387f5..6a2e4be 100644 --- a/src/store/reducers/RootReducer.ts +++ b/src/store/reducers/RootReducer.ts @@ -2,8 +2,10 @@ import { WalletManagerReducer } from "./WalletManagerReducer"; import { WalletsReducer } from "./WalletsReducer"; +import { SettingsReducer } from "./SettingsReducer"; export default combineReducers({ walletManager: WalletManagerReducer, - wallets: WalletsReducer + wallets: WalletsReducer, + settings: SettingsReducer, }); diff --git a/src/store/reducers/SettingsReducer.ts b/src/store/reducers/SettingsReducer.ts new file mode 100644 index 0000000..a888d3f --- /dev/null +++ b/src/store/reducers/SettingsReducer.ts @@ -0,0 +1,15 @@ +import { createReducer, ActionType } from "typesafe-actions"; +import { loadSettings, SettingsState } from "../../utils/settings"; +import { setBooleanSetting } from "../actions/Settings"; + +export type State = SettingsState; + +export function getInitialSettingsState(): State { + return loadSettings(); +} + +export const SettingsReducer = createReducer({} as State) + .handleAction(setBooleanSetting, (state: State, action: ActionType) => ({ + ...state, + [action.payload.settingName]: action.payload.value + })); diff --git a/src/style/components.less b/src/style/components.less index 8d6e34a..1909599 100644 --- a/src/style/components.less +++ b/src/style/components.less @@ -6,6 +6,8 @@ .ant-menu-item { border-bottom: 1px solid @border-color-split; margin-bottom: 0; + + user-select: none; } } diff --git a/src/utils/settings.ts b/src/utils/settings.ts new file mode 100644 index 0000000..b43fd94 --- /dev/null +++ b/src/utils/settings.ts @@ -0,0 +1,46 @@ +import { PickByValue } from "utility-types"; + +import { AppDispatch } from "../App"; +import * as actions from "../store/actions/Settings"; + +export interface SettingsState { + /** Whether or not advanced wallet formats are enabled. */ + readonly walletFormats: boolean; +} + +export const DEFAULT_SETTINGS: SettingsState = { + walletFormats: false +}; + +export type AnySettingName = keyof SettingsState; +export type SettingName = keyof PickByValue; + +export const getSettingKey = (settingName: AnySettingName): string => + "settings." + settingName; + +export function loadSettings(): SettingsState { + // Import the default settings first + const settings = { ...DEFAULT_SETTINGS }; + + // Using the default settings as a template, import the settings from local + // storage + for (const [settingName, value] of Object.entries(settings) as [AnySettingName, any][]) { + const stored = localStorage.getItem(getSettingKey(settingName)); + if (stored === null) continue; + + switch (typeof value) { + case "boolean": + settings[settingName] = stored === "true"; + break; + } + + // TODO: more setting types + } + + return settings; +} + +export function setBooleanSetting(dispatch: AppDispatch, settingName: SettingName, value: boolean): void { + localStorage.setItem(getSettingKey(settingName), value ? "true" : "false"); + dispatch(actions.setBooleanSetting(settingName, value)); +}