diff --git a/.vscode/settings.json b/.vscode/settings.json
index 526aafd..8f62ac6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -52,6 +52,7 @@
"midiots",
"motd",
"multiline",
+ "nolink",
"optimisation",
"personalise",
"pkgbuild",
diff --git a/package.json b/package.json
index 6938199..7d39e35 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"csv-stringify": "^5.6.2",
"dayjs": "^1.10.4",
"debug": "^4.3.1",
+ "fast-equals": "^2.0.0",
"file-saver": "^2.0.5",
"i18next": "^20.1.0",
"i18next-browser-languagedetector": "^6.1.0",
diff --git a/public/locales/en.json b/public/locales/en.json
index 8a27e0c..64d053e 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -404,6 +404,10 @@
"menuLanguage": "Language",
+ "subMenuBackups": "Manage backups",
+ "importBackup": "Import wallets",
+ "exportBackup": "Export wallets",
+
"subMenuAutoRefresh": "Auto-refresh",
"autoRefreshTables": "Auto-refresh tables",
"autoRefreshTablesDescription": "Whether or not large table listings (e.g. transactions, names) should automatically refresh when a change is detected on the network.",
diff --git a/src/components/ConditionalLink.tsx b/src/components/ConditionalLink.tsx
index 906f8ec..dc51c46 100644
--- a/src/components/ConditionalLink.tsx
+++ b/src/components/ConditionalLink.tsx
@@ -5,6 +5,8 @@
import { Link, useRouteMatch } from "react-router-dom";
+import "./styles/ConditionalLink.less";
+
interface Props {
to?: string;
condition?: boolean;
@@ -56,8 +58,8 @@
)
: (
-
+
{children}
-
+
);
};
diff --git a/src/components/DateTime.less b/src/components/DateTime.less
deleted file mode 100644
index 80de1e2..0000000
--- a/src/components/DateTime.less
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-@import (reference) "../App.less";
-
-.date-time {
- &-secondary, &-secondary a, &-secondary time {
- color: @text-color-secondary;
- }
-
- &-small, &-small a, &-small time {
- font-size: 90%;
-
- @media (max-width: @screen-xl) {
- font-size: 85%;
- }
- }
-}
diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx
index b00079d..0e2ff5e 100644
--- a/src/components/DateTime.tsx
+++ b/src/components/DateTime.tsx
@@ -11,7 +11,7 @@
import dayjs from "dayjs";
import TimeAgo from "react-timeago";
-import "./DateTime.less";
+import "./styles/DateTime.less";
import Debug from "debug";
const debug = Debug("kristweb:date-time");
diff --git a/src/components/Flag.css b/src/components/Flag.css
deleted file mode 100644
index be629f0..0000000
--- a/src/components/Flag.css
+++ /dev/null
@@ -1 +0,0 @@
-span.flag{width:44px;height:30px;display:inline-block}img.flag{width:30px}.flag{background:url(/img/flags_responsive.png) no-repeat;background-size:100%}.flag-ad{background-position:0 .413223%}.flag-ae{background-position:0 .826446%}.flag-af{background-position:0 1.239669%}.flag-ag{background-position:0 1.652893%}.flag-ai{background-position:0 2.066116%}.flag-al{background-position:0 2.479339%}.flag-am{background-position:0 2.892562%}.flag-an{background-position:0 3.305785%}.flag-ao{background-position:0 3.719008%}.flag-aq{background-position:0 4.132231%}.flag-ar{background-position:0 4.545455%}.flag-as{background-position:0 4.958678%}.flag-at{background-position:0 5.371901%}.flag-au{background-position:0 5.785124%}.flag-aw{background-position:0 6.198347%}.flag-az{background-position:0 6.61157%}.flag-ba{background-position:0 7.024793%}.flag-bb{background-position:0 7.438017%}.flag-bd{background-position:0 7.85124%}.flag-be{background-position:0 8.264463%}.flag-bf{background-position:0 8.677686%}.flag-bg{background-position:0 9.090909%}.flag-bh{background-position:0 9.504132%}.flag-bi{background-position:0 9.917355%}.flag-bj{background-position:0 10.330579%}.flag-bm{background-position:0 10.743802%}.flag-bn{background-position:0 11.157025%}.flag-bo{background-position:0 11.570248%}.flag-br{background-position:0 11.983471%}.flag-bs{background-position:0 12.396694%}.flag-bt{background-position:0 12.809917%}.flag-bv{background-position:0 13.22314%}.flag-bw{background-position:0 13.636364%}.flag-by{background-position:0 14.049587%}.flag-bz{background-position:0 14.46281%}.flag-ca{background-position:0 14.876033%}.flag-cc{background-position:0 15.289256%}.flag-cd{background-position:0 15.702479%}.flag-cf{background-position:0 16.115702%}.flag-cg{background-position:0 16.528926%}.flag-ch{background-position:0 16.942149%}.flag-ci{background-position:0 17.355372%}.flag-ck{background-position:0 17.768595%}.flag-cl{background-position:0 18.181818%}.flag-cm{background-position:0 18.595041%}.flag-cn{background-position:0 19.008264%}.flag-co{background-position:0 19.421488%}.flag-cr{background-position:0 19.834711%}.flag-cu{background-position:0 20.247934%}.flag-cv{background-position:0 20.661157%}.flag-cx{background-position:0 21.07438%}.flag-cy{background-position:0 21.487603%}.flag-cz{background-position:0 21.900826%}.flag-de{background-position:0 22.31405%}.flag-dj{background-position:0 22.727273%}.flag-dk{background-position:0 23.140496%}.flag-dm{background-position:0 23.553719%}.flag-do{background-position:0 23.966942%}.flag-dz{background-position:0 24.380165%}.flag-ec{background-position:0 24.793388%}.flag-ee{background-position:0 25.206612%}.flag-eg{background-position:0 25.619835%}.flag-eh{background-position:0 26.033058%}.flag-er{background-position:0 26.446281%}.flag-es{background-position:0 26.859504%}.flag-et{background-position:0 27.272727%}.flag-fi{background-position:0 27.68595%}.flag-fj{background-position:0 28.099174%}.flag-fk{background-position:0 28.512397%}.flag-fm{background-position:0 28.92562%}.flag-fo{background-position:0 29.338843%}.flag-fr{background-position:0 29.752066%}.flag-ga{background-position:0 30.165289%}.flag-gd{background-position:0 30.578512%}.flag-ge{background-position:0 30.991736%}.flag-gf{background-position:0 31.404959%}.flag-gh{background-position:0 31.818182%}.flag-gi{background-position:0 32.231405%}.flag-gl{background-position:0 32.644628%}.flag-gm{background-position:0 33.057851%}.flag-gn{background-position:0 33.471074%}.flag-gp{background-position:0 33.884298%}.flag-gq{background-position:0 34.297521%}.flag-gr{background-position:0 34.710744%}.flag-gs{background-position:0 35.123967%}.flag-gt{background-position:0 35.53719%}.flag-gu{background-position:0 35.950413%}.flag-gw{background-position:0 36.363636%}.flag-gy{background-position:0 36.77686%}.flag-hk{background-position:0 37.190083%}.flag-hm{background-position:0 37.603306%}.flag-hn{background-position:0 38.016529%}.flag-hr{background-position:0 38.429752%}.flag-ht{background-position:0 38.842975%}.flag-hu{background-position:0 39.256198%}.flag-id{background-position:0 39.669421%}.flag-ie{background-position:0 40.082645%}.flag-il{background-position:0 40.495868%}.flag-in{background-position:0 40.909091%}.flag-io{background-position:0 41.322314%}.flag-iq{background-position:0 41.735537%}.flag-ir{background-position:0 42.14876%}.flag-is{background-position:0 42.561983%}.flag-it{background-position:0 42.975207%}.flag-jm{background-position:0 43.38843%}.flag-jo{background-position:0 43.801653%}.flag-jp{background-position:0 44.214876%}.flag-ke{background-position:0 44.628099%}.flag-kg{background-position:0 45.041322%}.flag-kh{background-position:0 45.454545%}.flag-ki{background-position:0 45.867769%}.flag-km{background-position:0 46.280992%}.flag-kn{background-position:0 46.694215%}.flag-kp{background-position:0 47.107438%}.flag-kr{background-position:0 47.520661%}.flag-kw{background-position:0 47.933884%}.flag-ky{background-position:0 48.347107%}.flag-kz{background-position:0 48.760331%}.flag-la{background-position:0 49.173554%}.flag-lb{background-position:0 49.586777%}.flag-lc{background-position:0 50%}.flag-li{background-position:0 50.413223%}.flag-lk{background-position:0 50.826446%}.flag-lr{background-position:0 51.239669%}.flag-ls{background-position:0 51.652893%}.flag-lt{background-position:0 52.066116%}.flag-lu{background-position:0 52.479339%}.flag-lv{background-position:0 52.892562%}.flag-ly{background-position:0 53.305785%}.flag-ma{background-position:0 53.719008%}.flag-mc{background-position:0 54.132231%}.flag-md{background-position:0 54.545455%}.flag-me{background-position:0 54.958678%}.flag-mg{background-position:0 55.371901%}.flag-mh{background-position:0 55.785124%}.flag-mk{background-position:0 56.198347%}.flag-ml{background-position:0 56.61157%}.flag-mm{background-position:0 57.024793%}.flag-mn{background-position:0 57.438017%}.flag-mo{background-position:0 57.85124%}.flag-mp{background-position:0 58.264463%}.flag-mq{background-position:0 58.677686%}.flag-mr{background-position:0 59.090909%}.flag-ms{background-position:0 59.504132%}.flag-mt{background-position:0 59.917355%}.flag-mu{background-position:0 60.330579%}.flag-mv{background-position:0 60.743802%}.flag-mw{background-position:0 61.157025%}.flag-mx{background-position:0 61.570248%}.flag-my{background-position:0 61.983471%}.flag-mz{background-position:0 62.396694%}.flag-na{background-position:0 62.809917%}.flag-nc{background-position:0 63.22314%}.flag-ne{background-position:0 63.636364%}.flag-nf{background-position:0 64.049587%}.flag-ng{background-position:0 64.46281%}.flag-ni{background-position:0 64.876033%}.flag-nl{background-position:0 65.289256%}.flag-no{background-position:0 65.702479%}.flag-np{background-position:0 66.115702%}.flag-nr{background-position:0 66.528926%}.flag-nu{background-position:0 66.942149%}.flag-nz{background-position:0 67.355372%}.flag-om{background-position:0 67.768595%}.flag-pa{background-position:0 68.181818%}.flag-pe{background-position:0 68.595041%}.flag-pf{background-position:0 69.008264%}.flag-pg{background-position:0 69.421488%}.flag-ph{background-position:0 69.834711%}.flag-pk{background-position:0 70.247934%}.flag-pl{background-position:0 70.661157%}.flag-pm{background-position:0 71.07438%}.flag-pn{background-position:0 71.487603%}.flag-pr{background-position:0 71.900826%}.flag-pt{background-position:0 72.31405%}.flag-pw{background-position:0 72.727273%}.flag-py{background-position:0 73.140496%}.flag-qa{background-position:0 73.553719%}.flag-re{background-position:0 73.966942%}.flag-ro{background-position:0 74.380165%}.flag-rs{background-position:0 74.793388%}.flag-ru{background-position:0 75.206612%}.flag-rw{background-position:0 75.619835%}.flag-sa{background-position:0 76.033058%}.flag-sb{background-position:0 76.446281%}.flag-sc{background-position:0 76.859504%}.flag-sd{background-position:0 77.272727%}.flag-se{background-position:0 77.68595%}.flag-sg{background-position:0 78.099174%}.flag-sh{background-position:0 78.512397%}.flag-si{background-position:0 78.92562%}.flag-sj{background-position:0 79.338843%}.flag-sk{background-position:0 79.752066%}.flag-sl{background-position:0 80.165289%}.flag-sm{background-position:0 80.578512%}.flag-sn{background-position:0 80.991736%}.flag-so{background-position:0 81.404959%}.flag-sr{background-position:0 81.818182%}.flag-ss{background-position:0 82.231405%}.flag-st{background-position:0 82.644628%}.flag-sv{background-position:0 83.057851%}.flag-sy{background-position:0 83.471074%}.flag-sz{background-position:0 83.884298%}.flag-tc{background-position:0 84.297521%}.flag-td{background-position:0 84.710744%}.flag-tf{background-position:0 85.123967%}.flag-tg{background-position:0 85.53719%}.flag-th{background-position:0 85.950413%}.flag-tj{background-position:0 86.363636%}.flag-tk{background-position:0 86.77686%}.flag-tl{background-position:0 87.190083%}.flag-tm{background-position:0 87.603306%}.flag-tn{background-position:0 88.016529%}.flag-to{background-position:0 88.429752%}.flag-tp{background-position:0 88.842975%}.flag-tr{background-position:0 89.256198%}.flag-tt{background-position:0 89.669421%}.flag-tv{background-position:0 90.082645%}.flag-tw{background-position:0 90.495868%}.flag-ty{background-position:0 90.909091%}.flag-tz{background-position:0 91.322314%}.flag-ua{background-position:0 91.735537%}.flag-ug{background-position:0 92.14876%}.flag-gb,.flag-uk{background-position:0 92.561983%}.flag-um{background-position:0 92.975207%}.flag-us{background-position:0 93.38843%}.flag-uy{background-position:0 93.801653%}.flag-uz{background-position:0 94.214876%}.flag-va{background-position:0 94.628099%}.flag-vc{background-position:0 95.041322%}.flag-ve{background-position:0 95.454545%}.flag-vg{background-position:0 95.867769%}.flag-vi{background-position:0 96.280992%}.flag-vn{background-position:0 96.694215%}.flag-vu{background-position:0 97.107438%}.flag-wf{background-position:0 97.520661%}.flag-ws{background-position:0 97.933884%}.flag-ye{background-position:0 98.347107%}.flag-za{background-position:0 98.760331%}.flag-zm{background-position:0 99.173554%}.flag-zr{background-position:0 99.586777%}.flag-zw{background-position:0 100%}
diff --git a/src/components/Flag.tsx b/src/components/Flag.tsx
index 34fa672..cdc5ef0 100644
--- a/src/components/Flag.tsx
+++ b/src/components/Flag.tsx
@@ -4,7 +4,7 @@
import { HTMLProps } from "react";
import classNames from "classnames";
-import "./Flag.css";
+import "./styles/Flag.css";
interface Props extends HTMLProps {
name?: string;
diff --git a/src/components/HelpIcon.less b/src/components/HelpIcon.less
deleted file mode 100644
index e819c38..0000000
--- a/src/components/HelpIcon.less
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-@import (reference) "../App.less";
-
-.kw-help-icon {
- display: inline-block;
- margin-left: @padding-xs;
-
- font-size: 90%;
-
- color: @text-color-secondary;
- cursor: pointer;
-}
diff --git a/src/components/HelpIcon.tsx b/src/components/HelpIcon.tsx
index ae0c7c1..45e6b66 100644
--- a/src/components/HelpIcon.tsx
+++ b/src/components/HelpIcon.tsx
@@ -7,7 +7,7 @@
import { useTranslation } from "react-i18next";
-import "./HelpIcon.less";
+import "./styles/HelpIcon.less";
interface Props {
text?: string;
diff --git a/src/components/OptionalField.less b/src/components/OptionalField.less
deleted file mode 100644
index fae29a4..0000000
--- a/src/components/OptionalField.less
+++ /dev/null
@@ -1,11 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-@import (reference) "../App.less";
-
-.optional-field {
- &.optional-field-unset {
- color: @text-color-secondary;
- font-style: italic;
- }
-}
diff --git a/src/components/OptionalField.tsx b/src/components/OptionalField.tsx
index ecfed07..ae498fa 100644
--- a/src/components/OptionalField.tsx
+++ b/src/components/OptionalField.tsx
@@ -7,7 +7,7 @@
import { useTranslation } from "react-i18next";
-import "./OptionalField.less";
+import "./styles/OptionalField.less";
const { Text } = Typography;
diff --git a/src/components/SmallCopyable.less b/src/components/SmallCopyable.less
deleted file mode 100644
index 7a51c0b..0000000
--- a/src/components/SmallCopyable.less
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-.small-copyable {
- border: 0;
- background: transparent;
- padding: 0;
- line-height: inherit;
- display: inline-block;
-}
diff --git a/src/components/SmallCopyable.tsx b/src/components/SmallCopyable.tsx
index 04c667b..259cac3 100644
--- a/src/components/SmallCopyable.tsx
+++ b/src/components/SmallCopyable.tsx
@@ -1,6 +1,20 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+// -----------------------------------------------------------------------------
+// This is based on the ant Typography copyable, but with some features removed,
+// and without the overhead of the Typography Base component. The ResizeObserver
+// in Typography seems to add a significant amount to render times when there
+// are a lot of Text elements on the screen (for example, a table listing).
+//
+// This file is based off of the following source code from ant-design, which is
+// licensed under the MIT license:
+//
+// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/typography/Base.tsx
+//
+// For the full terms of the MIT license used by ant-design, see:
+// https://github.com/ant-design/ant-design/blob/master/LICENSE
+// -----------------------------------------------------------------------------
import { useState, useEffect, useRef } from "react";
import classNames from "classnames";
import { Tooltip } from "antd";
@@ -11,12 +25,7 @@
import { CopyConfig } from "./types";
import copy from "copy-to-clipboard";
-import "./SmallCopyable.less";
-
-// This is based on the ant Typography copyable, but with some features removed,
-// and without the overhead of the Typography Base component. The ResizeObserver
-// in Typography seems to add a significant amount to render times when there
-// are a lot of Text elements on the screen (for example, a table listing).
+import "./styles/SmallCopyable.less";
// Force 'text' to be set (don't traverse the children at all)
type Props = CopyConfig & {
diff --git a/src/components/Statistic.less b/src/components/Statistic.less
deleted file mode 100644
index e7c7597..0000000
--- a/src/components/Statistic.less
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-@import (reference) "../App.less";
-
-.kw-statistic {
- &-title {
- color: @kw-text-secondary;
- display: block;
- }
-
- &-value {
- font-size: @heading-3-size;
-
- .ant-typography-copy {
- line-height: 1 !important;
- margin-left: @padding-xs;
-
- .anticon {
- font-size: @font-size-base;
- vertical-align: 0;
- }
- }
- }
-
- &-green &-value {
- color: @kw-green;
- }
-}
diff --git a/src/components/Statistic.tsx b/src/components/Statistic.tsx
index 30372b5..42e7f6f 100644
--- a/src/components/Statistic.tsx
+++ b/src/components/Statistic.tsx
@@ -5,7 +5,7 @@
import { useTranslation } from "react-i18next";
-import "./Statistic.less";
+import "./styles/Statistic.less";
interface Props {
title?: string;
diff --git a/src/components/addresses/ContextualAddress.less b/src/components/addresses/ContextualAddress.less
index f5c46e5..d030790 100644
--- a/src/components/addresses/ContextualAddress.less
+++ b/src/components/addresses/ContextualAddress.less
@@ -19,23 +19,27 @@
opacity: 0.8;
}
- &.contextual-address-non-existent a {
- color: @text-color-secondary;
+ &.contextual-address-non-existent {
+ &, a {
+ color: @text-color-secondary;
- cursor: not-allowed;
+ cursor: not-allowed;
- text-decoration-line: underline;
- text-decoration-style: dotted;
- text-decoration-color: @text-color-secondary;
- text-decoration-thickness: 1px;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+ text-decoration-color: @text-color-secondary;
+ text-decoration-thickness: 1px;
+ }
}
.address-verified {
- &:not(.address-verified-inactive) a {
- color: fade(@kw-orange, 60%);
+ &:not(.address-verified-inactive) {
+ &, a {
+ color: fade(@kw-orange, 60%);
- .address-verified-label, .kw-verified-check-icon {
- color: @kw-orange;
+ .address-verified-label, .kw-verified-check-icon {
+ color: @kw-orange;
+ }
}
}
diff --git a/src/components/addresses/ContextualAddress.tsx b/src/components/addresses/ContextualAddress.tsx
index 64e0070..294b835 100644
--- a/src/components/addresses/ContextualAddress.tsx
+++ b/src/components/addresses/ContextualAddress.tsx
@@ -10,8 +10,7 @@
import { KristAddress } from "@api/types";
import { Wallet, useWallets } from "@wallets";
import { Contact, useContacts } from "@contacts";
-import { parseCommonMeta, CommonMeta } from "@utils/commonmeta";
-import { useNameSuffix, stripNameSuffix } from "@utils/currency";
+import { parseCommonMeta, CommonMeta, useNameSuffix, stripNameSuffix } from "@utils/krist";
import { useBooleanSetting } from "@utils/settings";
import { KristNameLink } from "../names/KristNameLink";
@@ -133,8 +132,7 @@
// Display the regular address or label
({address})
diff --git a/src/components/addresses/VerifiedAddress.tsx b/src/components/addresses/VerifiedAddress.tsx
index 7621c01..fa1d617 100644
--- a/src/components/addresses/VerifiedAddress.tsx
+++ b/src/components/addresses/VerifiedAddress.tsx
@@ -56,8 +56,7 @@
{parens && <>(>}
diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx
index e73ac2e..74115b4 100644
--- a/src/components/addresses/picker/AddressPicker.tsx
+++ b/src/components/addresses/picker/AddressPicker.tsx
@@ -16,7 +16,7 @@
useAddressPrefix, useNameSuffix,
isValidAddress, getNameParts,
getNameRegex, getAddressRegexV2
-} from "@utils/currency";
+} from "@utils/krist";
import { getCategoryHeader } from "./Header";
import { getAddressItem } from "./Item";
diff --git a/src/components/addresses/picker/Item.tsx b/src/components/addresses/picker/Item.tsx
index 679d0c0..1c1f34f 100644
--- a/src/components/addresses/picker/Item.tsx
+++ b/src/components/addresses/picker/Item.tsx
@@ -4,7 +4,7 @@
import { Wallet } from "@wallets";
import { Contact } from "@contacts";
-import { NameParts } from "@utils/currency";
+import { NameParts } from "@utils/krist";
import { KristValue } from "@comp/krist/KristValue";
diff --git a/src/components/addresses/picker/PickerHints.tsx b/src/components/addresses/picker/PickerHints.tsx
index ac48913..4f8492a 100644
--- a/src/components/addresses/picker/PickerHints.tsx
+++ b/src/components/addresses/picker/PickerHints.tsx
@@ -6,7 +6,7 @@
import {
isValidAddress, getNameParts,
useAddressPrefix, useNameSuffix
-} from "@utils/currency";
+} from "@utils/krist";
import { useWallets } from "@wallets";
import * as api from "@api";
diff --git a/src/components/names/KristNameLink.tsx b/src/components/names/KristNameLink.tsx
index b147992..4bd1a3c 100644
--- a/src/components/names/KristNameLink.tsx
+++ b/src/components/names/KristNameLink.tsx
@@ -6,7 +6,7 @@
import { ConditionalLink } from "@comp/ConditionalLink";
-import { useNameSuffix } from "@utils/currency";
+import { useNameSuffix } from "@utils/krist";
import { useBooleanSetting } from "@utils/settings";
const { Text } = Typography;
@@ -37,8 +37,7 @@
return
{content}
diff --git a/src/components/names/NameARecordLink.tsx b/src/components/names/NameARecordLink.tsx
index 55027f3..8298fad 100644
--- a/src/components/names/NameARecordLink.tsx
+++ b/src/components/names/NameARecordLink.tsx
@@ -3,7 +3,7 @@
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import classNames from "classnames";
-import { useNameSuffix, stripNameSuffix } from "@utils/currency";
+import { useNameSuffix, stripNameSuffix } from "@utils/krist";
import { KristNameLink } from "./KristNameLink";
diff --git a/src/components/results/SmallResult.tsx b/src/components/results/SmallResult.tsx
index 2838485..5e2f80f 100644
--- a/src/components/results/SmallResult.tsx
+++ b/src/components/results/SmallResult.tsx
@@ -1,10 +1,18 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-
-/** This is ant-design's Result component, but without importing 54 kB of
- * images that we don't even use */
-
+// -----------------------------------------------------------------------------
+// This is ant-design's Result component, but without importing 54 kB of images
+// that we don't even use.
+//
+// This file is based off of hte following source code from ant-design, which is
+// licensed under the MIT license:
+//
+// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/result/index.tsx
+//
+// For the full terms of the MIT license used by ant-design, see:
+// https://github.com/ant-design/ant-design/blob/master/LICENSE
+// -----------------------------------------------------------------------------
import React from "react";
import classNames from "classnames";
diff --git a/src/components/styles/ConditionalLink.less b/src/components/styles/ConditionalLink.less
new file mode 100644
index 0000000..f528f6b
--- /dev/null
+++ b/src/components/styles/ConditionalLink.less
@@ -0,0 +1,9 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.conditional-link-disabled {
+ color: @primary-color;
+ cursor: pointer;
+}
diff --git a/src/components/styles/DateTime.less b/src/components/styles/DateTime.less
new file mode 100644
index 0000000..3b0be63
--- /dev/null
+++ b/src/components/styles/DateTime.less
@@ -0,0 +1,18 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.date-time {
+ &-secondary, &-secondary a, &-secondary time {
+ color: @text-color-secondary;
+ }
+
+ &-small, &-small a, &-small time {
+ font-size: 90%;
+
+ @media (max-width: @screen-xl) {
+ font-size: 85%;
+ }
+ }
+}
diff --git a/src/components/styles/Flag.css b/src/components/styles/Flag.css
new file mode 100644
index 0000000..be629f0
--- /dev/null
+++ b/src/components/styles/Flag.css
@@ -0,0 +1 @@
+span.flag{width:44px;height:30px;display:inline-block}img.flag{width:30px}.flag{background:url(/img/flags_responsive.png) no-repeat;background-size:100%}.flag-ad{background-position:0 .413223%}.flag-ae{background-position:0 .826446%}.flag-af{background-position:0 1.239669%}.flag-ag{background-position:0 1.652893%}.flag-ai{background-position:0 2.066116%}.flag-al{background-position:0 2.479339%}.flag-am{background-position:0 2.892562%}.flag-an{background-position:0 3.305785%}.flag-ao{background-position:0 3.719008%}.flag-aq{background-position:0 4.132231%}.flag-ar{background-position:0 4.545455%}.flag-as{background-position:0 4.958678%}.flag-at{background-position:0 5.371901%}.flag-au{background-position:0 5.785124%}.flag-aw{background-position:0 6.198347%}.flag-az{background-position:0 6.61157%}.flag-ba{background-position:0 7.024793%}.flag-bb{background-position:0 7.438017%}.flag-bd{background-position:0 7.85124%}.flag-be{background-position:0 8.264463%}.flag-bf{background-position:0 8.677686%}.flag-bg{background-position:0 9.090909%}.flag-bh{background-position:0 9.504132%}.flag-bi{background-position:0 9.917355%}.flag-bj{background-position:0 10.330579%}.flag-bm{background-position:0 10.743802%}.flag-bn{background-position:0 11.157025%}.flag-bo{background-position:0 11.570248%}.flag-br{background-position:0 11.983471%}.flag-bs{background-position:0 12.396694%}.flag-bt{background-position:0 12.809917%}.flag-bv{background-position:0 13.22314%}.flag-bw{background-position:0 13.636364%}.flag-by{background-position:0 14.049587%}.flag-bz{background-position:0 14.46281%}.flag-ca{background-position:0 14.876033%}.flag-cc{background-position:0 15.289256%}.flag-cd{background-position:0 15.702479%}.flag-cf{background-position:0 16.115702%}.flag-cg{background-position:0 16.528926%}.flag-ch{background-position:0 16.942149%}.flag-ci{background-position:0 17.355372%}.flag-ck{background-position:0 17.768595%}.flag-cl{background-position:0 18.181818%}.flag-cm{background-position:0 18.595041%}.flag-cn{background-position:0 19.008264%}.flag-co{background-position:0 19.421488%}.flag-cr{background-position:0 19.834711%}.flag-cu{background-position:0 20.247934%}.flag-cv{background-position:0 20.661157%}.flag-cx{background-position:0 21.07438%}.flag-cy{background-position:0 21.487603%}.flag-cz{background-position:0 21.900826%}.flag-de{background-position:0 22.31405%}.flag-dj{background-position:0 22.727273%}.flag-dk{background-position:0 23.140496%}.flag-dm{background-position:0 23.553719%}.flag-do{background-position:0 23.966942%}.flag-dz{background-position:0 24.380165%}.flag-ec{background-position:0 24.793388%}.flag-ee{background-position:0 25.206612%}.flag-eg{background-position:0 25.619835%}.flag-eh{background-position:0 26.033058%}.flag-er{background-position:0 26.446281%}.flag-es{background-position:0 26.859504%}.flag-et{background-position:0 27.272727%}.flag-fi{background-position:0 27.68595%}.flag-fj{background-position:0 28.099174%}.flag-fk{background-position:0 28.512397%}.flag-fm{background-position:0 28.92562%}.flag-fo{background-position:0 29.338843%}.flag-fr{background-position:0 29.752066%}.flag-ga{background-position:0 30.165289%}.flag-gd{background-position:0 30.578512%}.flag-ge{background-position:0 30.991736%}.flag-gf{background-position:0 31.404959%}.flag-gh{background-position:0 31.818182%}.flag-gi{background-position:0 32.231405%}.flag-gl{background-position:0 32.644628%}.flag-gm{background-position:0 33.057851%}.flag-gn{background-position:0 33.471074%}.flag-gp{background-position:0 33.884298%}.flag-gq{background-position:0 34.297521%}.flag-gr{background-position:0 34.710744%}.flag-gs{background-position:0 35.123967%}.flag-gt{background-position:0 35.53719%}.flag-gu{background-position:0 35.950413%}.flag-gw{background-position:0 36.363636%}.flag-gy{background-position:0 36.77686%}.flag-hk{background-position:0 37.190083%}.flag-hm{background-position:0 37.603306%}.flag-hn{background-position:0 38.016529%}.flag-hr{background-position:0 38.429752%}.flag-ht{background-position:0 38.842975%}.flag-hu{background-position:0 39.256198%}.flag-id{background-position:0 39.669421%}.flag-ie{background-position:0 40.082645%}.flag-il{background-position:0 40.495868%}.flag-in{background-position:0 40.909091%}.flag-io{background-position:0 41.322314%}.flag-iq{background-position:0 41.735537%}.flag-ir{background-position:0 42.14876%}.flag-is{background-position:0 42.561983%}.flag-it{background-position:0 42.975207%}.flag-jm{background-position:0 43.38843%}.flag-jo{background-position:0 43.801653%}.flag-jp{background-position:0 44.214876%}.flag-ke{background-position:0 44.628099%}.flag-kg{background-position:0 45.041322%}.flag-kh{background-position:0 45.454545%}.flag-ki{background-position:0 45.867769%}.flag-km{background-position:0 46.280992%}.flag-kn{background-position:0 46.694215%}.flag-kp{background-position:0 47.107438%}.flag-kr{background-position:0 47.520661%}.flag-kw{background-position:0 47.933884%}.flag-ky{background-position:0 48.347107%}.flag-kz{background-position:0 48.760331%}.flag-la{background-position:0 49.173554%}.flag-lb{background-position:0 49.586777%}.flag-lc{background-position:0 50%}.flag-li{background-position:0 50.413223%}.flag-lk{background-position:0 50.826446%}.flag-lr{background-position:0 51.239669%}.flag-ls{background-position:0 51.652893%}.flag-lt{background-position:0 52.066116%}.flag-lu{background-position:0 52.479339%}.flag-lv{background-position:0 52.892562%}.flag-ly{background-position:0 53.305785%}.flag-ma{background-position:0 53.719008%}.flag-mc{background-position:0 54.132231%}.flag-md{background-position:0 54.545455%}.flag-me{background-position:0 54.958678%}.flag-mg{background-position:0 55.371901%}.flag-mh{background-position:0 55.785124%}.flag-mk{background-position:0 56.198347%}.flag-ml{background-position:0 56.61157%}.flag-mm{background-position:0 57.024793%}.flag-mn{background-position:0 57.438017%}.flag-mo{background-position:0 57.85124%}.flag-mp{background-position:0 58.264463%}.flag-mq{background-position:0 58.677686%}.flag-mr{background-position:0 59.090909%}.flag-ms{background-position:0 59.504132%}.flag-mt{background-position:0 59.917355%}.flag-mu{background-position:0 60.330579%}.flag-mv{background-position:0 60.743802%}.flag-mw{background-position:0 61.157025%}.flag-mx{background-position:0 61.570248%}.flag-my{background-position:0 61.983471%}.flag-mz{background-position:0 62.396694%}.flag-na{background-position:0 62.809917%}.flag-nc{background-position:0 63.22314%}.flag-ne{background-position:0 63.636364%}.flag-nf{background-position:0 64.049587%}.flag-ng{background-position:0 64.46281%}.flag-ni{background-position:0 64.876033%}.flag-nl{background-position:0 65.289256%}.flag-no{background-position:0 65.702479%}.flag-np{background-position:0 66.115702%}.flag-nr{background-position:0 66.528926%}.flag-nu{background-position:0 66.942149%}.flag-nz{background-position:0 67.355372%}.flag-om{background-position:0 67.768595%}.flag-pa{background-position:0 68.181818%}.flag-pe{background-position:0 68.595041%}.flag-pf{background-position:0 69.008264%}.flag-pg{background-position:0 69.421488%}.flag-ph{background-position:0 69.834711%}.flag-pk{background-position:0 70.247934%}.flag-pl{background-position:0 70.661157%}.flag-pm{background-position:0 71.07438%}.flag-pn{background-position:0 71.487603%}.flag-pr{background-position:0 71.900826%}.flag-pt{background-position:0 72.31405%}.flag-pw{background-position:0 72.727273%}.flag-py{background-position:0 73.140496%}.flag-qa{background-position:0 73.553719%}.flag-re{background-position:0 73.966942%}.flag-ro{background-position:0 74.380165%}.flag-rs{background-position:0 74.793388%}.flag-ru{background-position:0 75.206612%}.flag-rw{background-position:0 75.619835%}.flag-sa{background-position:0 76.033058%}.flag-sb{background-position:0 76.446281%}.flag-sc{background-position:0 76.859504%}.flag-sd{background-position:0 77.272727%}.flag-se{background-position:0 77.68595%}.flag-sg{background-position:0 78.099174%}.flag-sh{background-position:0 78.512397%}.flag-si{background-position:0 78.92562%}.flag-sj{background-position:0 79.338843%}.flag-sk{background-position:0 79.752066%}.flag-sl{background-position:0 80.165289%}.flag-sm{background-position:0 80.578512%}.flag-sn{background-position:0 80.991736%}.flag-so{background-position:0 81.404959%}.flag-sr{background-position:0 81.818182%}.flag-ss{background-position:0 82.231405%}.flag-st{background-position:0 82.644628%}.flag-sv{background-position:0 83.057851%}.flag-sy{background-position:0 83.471074%}.flag-sz{background-position:0 83.884298%}.flag-tc{background-position:0 84.297521%}.flag-td{background-position:0 84.710744%}.flag-tf{background-position:0 85.123967%}.flag-tg{background-position:0 85.53719%}.flag-th{background-position:0 85.950413%}.flag-tj{background-position:0 86.363636%}.flag-tk{background-position:0 86.77686%}.flag-tl{background-position:0 87.190083%}.flag-tm{background-position:0 87.603306%}.flag-tn{background-position:0 88.016529%}.flag-to{background-position:0 88.429752%}.flag-tp{background-position:0 88.842975%}.flag-tr{background-position:0 89.256198%}.flag-tt{background-position:0 89.669421%}.flag-tv{background-position:0 90.082645%}.flag-tw{background-position:0 90.495868%}.flag-ty{background-position:0 90.909091%}.flag-tz{background-position:0 91.322314%}.flag-ua{background-position:0 91.735537%}.flag-ug{background-position:0 92.14876%}.flag-gb,.flag-uk{background-position:0 92.561983%}.flag-um{background-position:0 92.975207%}.flag-us{background-position:0 93.38843%}.flag-uy{background-position:0 93.801653%}.flag-uz{background-position:0 94.214876%}.flag-va{background-position:0 94.628099%}.flag-vc{background-position:0 95.041322%}.flag-ve{background-position:0 95.454545%}.flag-vg{background-position:0 95.867769%}.flag-vi{background-position:0 96.280992%}.flag-vn{background-position:0 96.694215%}.flag-vu{background-position:0 97.107438%}.flag-wf{background-position:0 97.520661%}.flag-ws{background-position:0 97.933884%}.flag-ye{background-position:0 98.347107%}.flag-za{background-position:0 98.760331%}.flag-zm{background-position:0 99.173554%}.flag-zr{background-position:0 99.586777%}.flag-zw{background-position:0 100%}
diff --git a/src/components/styles/HelpIcon.less b/src/components/styles/HelpIcon.less
new file mode 100644
index 0000000..161536c
--- /dev/null
+++ b/src/components/styles/HelpIcon.less
@@ -0,0 +1,14 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.kw-help-icon {
+ display: inline-block;
+ margin-left: @padding-xs;
+
+ font-size: 90%;
+
+ color: @text-color-secondary;
+ cursor: pointer;
+}
diff --git a/src/components/styles/OptionalField.less b/src/components/styles/OptionalField.less
new file mode 100644
index 0000000..61107db
--- /dev/null
+++ b/src/components/styles/OptionalField.less
@@ -0,0 +1,11 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.optional-field {
+ &.optional-field-unset {
+ color: @text-color-secondary;
+ font-style: italic;
+ }
+}
diff --git a/src/components/styles/SmallCopyable.less b/src/components/styles/SmallCopyable.less
new file mode 100644
index 0000000..7a51c0b
--- /dev/null
+++ b/src/components/styles/SmallCopyable.less
@@ -0,0 +1,10 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+.small-copyable {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ line-height: inherit;
+ display: inline-block;
+}
diff --git a/src/components/styles/Statistic.less b/src/components/styles/Statistic.less
new file mode 100644
index 0000000..e890edb
--- /dev/null
+++ b/src/components/styles/Statistic.less
@@ -0,0 +1,29 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../../App.less";
+
+.kw-statistic {
+ &-title {
+ color: @kw-text-secondary;
+ display: block;
+ }
+
+ &-value {
+ font-size: @heading-3-size;
+
+ .ant-typography-copy {
+ line-height: 1 !important;
+ margin-left: @padding-xs;
+
+ .anticon {
+ font-size: @font-size-base;
+ vertical-align: 0;
+ }
+ }
+ }
+
+ &-green &-value {
+ color: @kw-green;
+ }
+}
diff --git a/src/components/transactions/TransactionConciseMetadata.tsx b/src/components/transactions/TransactionConciseMetadata.tsx
index 4f7bf0f..a48963d 100644
--- a/src/components/transactions/TransactionConciseMetadata.tsx
+++ b/src/components/transactions/TransactionConciseMetadata.tsx
@@ -4,7 +4,7 @@
import classNames from "classnames";
import { KristTransaction } from "@api/types";
-import { useNameSuffix, stripNameFromMetadata } from "@utils/currency";
+import { useNameSuffix, stripNameFromMetadata } from "@utils/krist";
import "./TransactionConciseMetadata.less";
diff --git a/src/components/transactions/TransactionItem.tsx b/src/components/transactions/TransactionItem.tsx
index 7bf2354..95a26e7 100644
--- a/src/components/transactions/TransactionItem.tsx
+++ b/src/components/transactions/TransactionItem.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { Row, Col, Tooltip, Grid } from "antd";
+import { Row, Col, Tooltip, } from "antd";
import { RightOutlined } from "@ant-design/icons";
import { useTFns } from "@utils/i18n";
@@ -10,6 +10,7 @@
import { KristTransaction } from "@api/types";
import { WalletAddressMap, Wallet } from "@wallets";
+import { useBreakpoint } from "@utils/hooks";
import { DateTime } from "../DateTime";
@@ -42,8 +43,8 @@
export function TransactionItem({
transaction: tx,
wallets
-}: Props): JSX.Element {
- const bps = Grid.useBreakpoint();
+}: Props): JSX.Element | null {
+ const bps = useBreakpoint();
// Whether or not the from/to addresses are a wallet we own
const fromWallet = tx.from ? wallets[tx.from] : undefined;
@@ -58,7 +59,7 @@
// Return a different element (same data, different layout) depending on
// whether this is mobile or desktop
- return bps.sm
+ return bps.sm || bps.sm === undefined // bps can be undefined sometimes
?
)
: null
diff --git a/src/components/transactions/TransactionType.less b/src/components/transactions/TransactionType.less
index 10a467d..150c2f3 100644
--- a/src/components/transactions/TransactionType.less
+++ b/src/components/transactions/TransactionType.less
@@ -4,21 +4,31 @@
@import (reference) "../../App.less";
.transaction-type {
- a {
+ &, a {
user-select: none;
font-weight: bold;
color: @text-color-secondary;
}
- &-transferred a, &-name_transferred a { color: @kw-primary; }
- &-sent a, &-name_sent a, &-name_purchased a { color: @kw-orange; }
- &-received a, &-mined a, &-name_received a { color: @kw-green; }
- &-name_a_record a { color: @kw-purple; }
- &-bumped a { color: @kw-text-tertiary; }
+ &-transferred, &-name_transferred {
+ &, a { color: @kw-primary; }
+ }
+ &-sent, &-name_sent, &-name_purchased {
+ &, a { color: @kw-orange; }
+ }
+ &-received, &-mined, &-name_received {
+ &, a { color: @kw-green; }
+ }
+ &-name_a_record {
+ &, a { color: @kw-purple; }
+ }
+ &-bumped {
+ &, a { color: @kw-text-tertiary; }
+ }
- &-no-link a {
- cursor: default;
+ &-no-link {
+ &, a { cursor: default; }
}
@media (max-width: @screen-xl) {
diff --git a/src/components/transactions/TransactionType.tsx b/src/components/transactions/TransactionType.tsx
index 3cf11e7..c5c383e 100644
--- a/src/components/transactions/TransactionType.tsx
+++ b/src/components/transactions/TransactionType.tsx
@@ -85,13 +85,16 @@
});
return
-
- {contents}
-
+ {link
+ ? (
+
+ {contents}
+
+ )
+ : contents}
;
}
diff --git a/src/global/ForcedAuth.tsx b/src/global/ForcedAuth.tsx
index abaf804..b1458de 100644
--- a/src/global/ForcedAuth.tsx
+++ b/src/global/ForcedAuth.tsx
@@ -6,7 +6,7 @@
import { authMasterPassword, useMasterPassword } from "@wallets";
-import { useMountEffect } from "@utils";
+import { useMountEffect } from "@utils/hooks";
async function forceAuth(t: TFunction, salt: string, tester: string): Promise {
try {
diff --git a/src/global/ws/SyncMOTD.tsx b/src/global/ws/SyncMOTD.tsx
index c10d21f..4aa2c99 100644
--- a/src/global/ws/SyncMOTD.tsx
+++ b/src/global/ws/SyncMOTD.tsx
@@ -16,7 +16,7 @@
import {
recalculateWallets, useWallets, useMasterPasswordOnly
} from "@wallets";
-import { useAddressPrefix } from "@utils/currency";
+import { useAddressPrefix } from "@utils/krist";
import Debug from "debug";
const debug = Debug("kristweb:sync-motd");
diff --git a/src/krist/addressAlgo.ts b/src/krist/addressAlgo.ts
deleted file mode 100644
index 5183375..0000000
--- a/src/krist/addressAlgo.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { sha256, doubleSHA256 } from "@utils/crypto";
-
-const hexToBase36 = (input: number): string => {
- const byte = 48 + Math.floor(input / 7);
- return String.fromCharCode(byte + 39 > 122 ? 101 : byte > 57 ? byte + 39 : byte);
-};
-
-export const makeV2Address = async (addressPrefix: string, key: string): Promise => {
- const chars = ["", "", "", "", "", "", "", "", ""];
- let chain = addressPrefix;
- let hash = await doubleSHA256(key);
-
- for (let i = 0; i <= 8; i++) {
- chars[i] = hash.substring(0, 2);
- hash = await doubleSHA256(hash);
- }
-
- for (let i = 0; i <= 8;) {
- const index = parseInt(hash.substring(2 * i, 2 + (2 * i)), 16) % 9;
-
- if (chars[index] === "") {
- hash = await sha256(hash);
- } else {
- chain += hexToBase36(parseInt(chars[index], 16));
- chars[index] = "";
- i++;
- }
- }
-
- return chain;
-};
diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts
index 3e920f4..248c87f 100644
--- a/src/krist/api/lookup.ts
+++ b/src/krist/api/lookup.ts
@@ -4,7 +4,9 @@
import { KristAddress, KristTransaction, KristName, KristBlock } from "./types";
import * as api from ".";
-import { LookupFilterOptionsBase, LookupResponseBase, getFilterOptionsQuery } from "@utils/table";
+import {
+ LookupFilterOptionsBase, LookupResponseBase, getFilterOptionsQuery
+} from "@utils/table/table";
// =============================================================================
// Addresses
diff --git a/src/krist/wallets/utils.ts b/src/krist/wallets/utils.ts
index 5a5626b..f88dbd1 100644
--- a/src/krist/wallets/utils.ts
+++ b/src/krist/wallets/utils.ts
@@ -7,7 +7,7 @@
import { RootState } from "@store";
import { Wallet, WalletNew, WalletMap, WalletFormatName, applyWalletFormat } from ".";
-import { makeV2Address } from "../addressAlgo";
+import { makeV2Address } from "@utils/krist";
import { localeSort } from "@utils";
diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less
index 9cf68c2..ac87dd8 100644
--- a/src/layout/PageLayout.less
+++ b/src/layout/PageLayout.less
@@ -47,11 +47,11 @@
display: none;
}
- .ant-pagination-item, .ant-pagination-prev{
+ .ant-pagination-item, .ant-pagination-prev {
margin-right: 3px;
}
- .ant-pagination-next {
+ .ant-pagination-next {
margin-right: 0;
}
}
diff --git a/src/layout/nav/AppHeader.less b/src/layout/nav/AppHeader.less
index 8915393..6b3a5c7 100644
--- a/src/layout/nav/AppHeader.less
+++ b/src/layout/nav/AppHeader.less
@@ -41,7 +41,7 @@
border-right: 1px solid @kw-border-color-darker;
- a {
+ .conditional-link-disabled, a {
color: @text-color;
text-align: center;
@@ -210,6 +210,10 @@
font-size: @font-size-base;
vertical-align: -0.175em;
}
+
+ .conditional-link-disabled {
+ color: @text-color;
+ }
}
}
}
diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx
index 60c8b6b..f7edb31 100644
--- a/src/layout/nav/Search.tsx
+++ b/src/layout/nav/Search.tsx
@@ -2,7 +2,7 @@
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, useMemo, useRef, useEffect, useCallback, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react";
-import { AutoComplete, Input, Grid } from "antd";
+import { AutoComplete, Input } from "antd";
import { RefSelectProps } from "antd/lib/select";
import { useTranslation } from "react-i18next";
@@ -10,6 +10,7 @@
import { GlobalHotKeys } from "react-hotkeys";
import { ctrl } from "@utils";
+import { useBreakpoint } from "@utils/hooks";
import { RateLimitError } from "@api";
import { SearchResult, search, searchExtended, SearchExtendedResult } from "@api/search";
@@ -56,7 +57,7 @@
const history = useHistory();
// Used to change the placeholder depending on the screen width
- const bps = Grid.useBreakpoint();
+ const bps = useBreakpoint();
const [value, setValue] = useState("");
const [results, setResults] = useState();
diff --git a/src/layout/nav/SearchResults.less b/src/layout/nav/SearchResults.less
index f59b229..302ae9d 100644
--- a/src/layout/nav/SearchResults.less
+++ b/src/layout/nav/SearchResults.less
@@ -72,6 +72,10 @@
}
}
+ .conditional-link-disabled {
+ color: inherit;
+ }
+
// Hide some result information on very small screens
@media (max-width: 300px) {
.result-right {
diff --git a/src/layout/nav/TopMenu.tsx b/src/layout/nav/TopMenu.tsx
index 4d25f04..cc578f3 100644
--- a/src/layout/nav/TopMenu.tsx
+++ b/src/layout/nav/TopMenu.tsx
@@ -2,7 +2,7 @@
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useState, useCallback, useMemo, useContext, createContext, FC, ReactNode } from "react";
-import { Menu, Grid, Dropdown } from "antd";
+import { Menu, Dropdown } from "antd";
import {
MoreOutlined, SettingOutlined, SendOutlined, DownloadOutlined
} from "@ant-design/icons";
@@ -10,6 +10,7 @@
import { useTFns } from "@utils/i18n";
import { ConditionalLink } from "@comp/ConditionalLink";
+import { useBreakpoint } from "@utils/hooks";
import Debug from "debug";
const debug = Debug("kristweb:top-menu");
@@ -26,7 +27,7 @@
export function TopMenu(): JSX.Element {
const { tStr } = useTFns("nav.");
- const bps = Grid.useBreakpoint();
+ const bps = useBreakpoint();
const ctxRes = useContext(TopMenuContext);
const options = ctxRes?.options;
@@ -110,7 +111,7 @@
};
export function useTopMenuOptions(): [boolean, SetMenuOptsFn, () => void] {
- const bps = Grid.useBreakpoint();
+ const bps = useBreakpoint();
const { setMenuOptions } = useContext(TopMenuContext);
const set = useCallback((opts: Opts) => {
diff --git a/src/layout/sidebar/Sidebar.less b/src/layout/sidebar/Sidebar.less
index 828824e..b0a639e 100644
--- a/src/layout/sidebar/Sidebar.less
+++ b/src/layout/sidebar/Sidebar.less
@@ -114,6 +114,10 @@
font-size: 18px;
}
+
+ .conditional-link-disabled {
+ color: @text-color;
+ }
}
.site-sidebar-footer {
@@ -126,7 +130,7 @@
color: @text-color-secondary;
- a {
+ .conditional-link-disabled, a {
color: @text-color;
}
diff --git a/src/layout/sidebar/SidebarFooter.tsx b/src/layout/sidebar/SidebarFooter.tsx
index 91aab52..79b4304 100644
--- a/src/layout/sidebar/SidebarFooter.tsx
+++ b/src/layout/sidebar/SidebarFooter.tsx
@@ -3,7 +3,7 @@
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { useTranslation, Trans } from "react-i18next";
-import { getAuthorInfo, useHostInfo } from "@utils/credits";
+import { getAuthorInfo, useHostInfo } from "@utils";
import { ConditionalLink } from "@comp/ConditionalLink";
diff --git a/src/pages/addresses/AddressButtonRow.tsx b/src/pages/addresses/AddressButtonRow.tsx
index b97744b..2efefac 100644
--- a/src/pages/addresses/AddressButtonRow.tsx
+++ b/src/pages/addresses/AddressButtonRow.tsx
@@ -7,7 +7,7 @@
import { useTFns } from "@utils/i18n";
-import { isV1Address } from "@utils/currency";
+import { isV1Address } from "@utils/krist";
import { Wallet } from "@wallets";
import { Contact } from "@contacts";
diff --git a/src/pages/backup/backupImport.ts b/src/pages/backup/backupImport.ts
index f75bf0c..ded8204 100644
--- a/src/pages/backup/backupImport.ts
+++ b/src/pages/backup/backupImport.ts
@@ -5,8 +5,7 @@
import { TranslatedError } from "@utils/i18n";
-import { aesGcmDecrypt } from "@utils/crypto";
-import { decryptCryptoJS } from "@utils/CryptoJS";
+import { aesGcmDecrypt, decryptCryptoJS } from "@utils/crypto";
import {
Backup, BackupFormatType, isBackupKristWebV1, isBackupKristWebV2
diff --git a/src/pages/backup/backupImportUtils.ts b/src/pages/backup/backupImportUtils.ts
index 72c11ec..ccaaaf5 100644
--- a/src/pages/backup/backupImportUtils.ts
+++ b/src/pages/backup/backupImportUtils.ts
@@ -12,7 +12,7 @@
} from "@wallets";
import { ContactMap, Contact, addContact, editContactLabel } from "@contacts";
-import { isValidAddress, getNameParts } from "@utils/currency";
+import { isValidAddress, getNameParts } from "@utils/krist";
import Debug from "debug";
const debug = Debug("kristweb:backup-import-utils");
diff --git a/src/pages/blocks/BlocksPage.tsx b/src/pages/blocks/BlocksPage.tsx
index a60343e..0a860ee 100644
--- a/src/pages/blocks/BlocksPage.tsx
+++ b/src/pages/blocks/BlocksPage.tsx
@@ -11,7 +11,7 @@
import { BlocksTable } from "./BlocksTable";
import { useBooleanSetting } from "@utils/settings";
-import { useLinkedPagination } from "@utils/table";
+import { useLinkedPagination } from "@utils/table/table";
interface Props {
lowest?: boolean;
diff --git a/src/pages/blocks/BlocksTable.tsx b/src/pages/blocks/BlocksTable.tsx
index 643a865..b0c49a4 100644
--- a/src/pages/blocks/BlocksTable.tsx
+++ b/src/pages/blocks/BlocksTable.tsx
@@ -12,7 +12,7 @@
import { lookupBlocks, LookupBlocksOptions, LookupBlocksResponse } from "@api/lookup";
import {
useMalleablePagination, useTableHistory, useDateColumnWidth
-} from "@utils/table";
+} from "@utils/table/table";
import { ContextualAddress } from "@comp/addresses/ContextualAddress";
import { BlockHash } from "./BlockHash";
diff --git a/src/pages/contacts/AddContactModal.tsx b/src/pages/contacts/AddContactModal.tsx
index 090389a..2cc3823 100644
--- a/src/pages/contacts/AddContactModal.tsx
+++ b/src/pages/contacts/AddContactModal.tsx
@@ -8,7 +8,7 @@
import { ADDRESS_LIST_LIMIT } from "@wallets";
import { Contact, useContacts, addContact, editContact } from "@contacts";
-import { useNameSuffix, getNameParts } from "@utils/currency";
+import { useNameSuffix, getNameParts } from "@utils/krist";
import { AddressPicker } from "@comp/addresses/picker/AddressPicker";
diff --git a/src/pages/credits/CreditsPage.tsx b/src/pages/credits/CreditsPage.tsx
index 01ece3b..a8c141e 100644
--- a/src/pages/credits/CreditsPage.tsx
+++ b/src/pages/credits/CreditsPage.tsx
@@ -9,7 +9,7 @@
import { Translators } from "./Translators";
import { DateTime } from "@comp/DateTime";
-import { getAuthorInfo, useHostInfo } from "@utils/credits";
+import { getAuthorInfo, useHostInfo } from "@utils";
import "./CreditsPage.less";
diff --git a/src/pages/credits/Supporters.tsx b/src/pages/credits/Supporters.tsx
index e7efa71..54f23ee 100644
--- a/src/pages/credits/Supporters.tsx
+++ b/src/pages/credits/Supporters.tsx
@@ -8,7 +8,7 @@
import packageJson from "../../../package.json";
-import { useMountEffect } from "@utils";
+import { useMountEffect } from "@utils/hooks";
interface Supporter {
name: string;
diff --git a/src/pages/dashboard/BlockDifficultyCard.tsx b/src/pages/dashboard/BlockDifficultyCard.tsx
index 188df39..a2e2c2b 100644
--- a/src/pages/dashboard/BlockDifficultyCard.tsx
+++ b/src/pages/dashboard/BlockDifficultyCard.tsx
@@ -12,9 +12,9 @@
import { Line } from "react-chartjs-2";
import * as api from "@api";
-import { estimateHashRate } from "@utils/currency";
+import { estimateHashRate } from "@utils/krist";
import { KristConstants } from "@api/types";
-import { trailingThrottleState } from "@utils/promiseThrottle";
+import { trailingThrottleState } from "@utils";
import { SmallResult } from "@comp/results/SmallResult";
import { Statistic } from "@comp/Statistic";
diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx
index d44b003..28b374c 100644
--- a/src/pages/dashboard/DashboardPage.tsx
+++ b/src/pages/dashboard/DashboardPage.tsx
@@ -18,7 +18,7 @@
import { TipsCard } from "./TipsCard";
import { useSyncNode } from "@api";
-import { getAuthorInfo } from "@utils/credits";
+import { getAuthorInfo } from "@utils";
import { SyncDetailedWork } from "@global/ws/SyncDetailedWork";
import "./DashboardPage.less";
diff --git a/src/pages/dashboard/InDevBanner.tsx b/src/pages/dashboard/InDevBanner.tsx
index 088f533..50e5ed8 100644
--- a/src/pages/dashboard/InDevBanner.tsx
+++ b/src/pages/dashboard/InDevBanner.tsx
@@ -5,7 +5,7 @@
import { useTranslation, Trans } from "react-i18next";
-import { getAuthorInfo } from "@utils/credits";
+import { getAuthorInfo } from "@utils";
export function InDevBanner(): JSX.Element {
const { t } = useTranslation();
diff --git a/src/pages/dashboard/TipsCard.tsx b/src/pages/dashboard/TipsCard.tsx
index b521639..4a9b43b 100644
--- a/src/pages/dashboard/TipsCard.tsx
+++ b/src/pages/dashboard/TipsCard.tsx
@@ -8,7 +8,8 @@
import { RootState } from "@store";
import { setTip } from "@actions/MiscActions";
-import { mod, useMountEffect } from "@utils";
+import { mod } from "@utils";
+import { useMountEffect } from "@utils/hooks";
import { useTFns } from "@utils/i18n";
import Markdown from "markdown-to-jsx";
diff --git a/src/pages/dashboard/TransactionsCard.tsx b/src/pages/dashboard/TransactionsCard.tsx
index 635bf73..bf2c4a7 100644
--- a/src/pages/dashboard/TransactionsCard.tsx
+++ b/src/pages/dashboard/TransactionsCard.tsx
@@ -15,7 +15,7 @@
import { SmallResult } from "@comp/results/SmallResult";
-import { trailingThrottleState } from "@utils/promiseThrottle";
+import { trailingThrottleState } from "@utils";
import Debug from "debug";
const debug = Debug("kristweb:transactions-card");
diff --git a/src/pages/names/NamePage.tsx b/src/pages/names/NamePage.tsx
index d1a90d7..3469d8e 100644
--- a/src/pages/names/NamePage.tsx
+++ b/src/pages/names/NamePage.tsx
@@ -21,7 +21,7 @@
import { LookupTransactionType as LookupTXType } from "@api/lookup";
import { useWallets } from "@wallets";
-import { useNameSuffix } from "@utils/currency";
+import { useNameSuffix } from "@utils/krist";
import { useSubscription } from "@global/ws/WebsocketSubscription";
import { useBooleanSetting } from "@utils/settings";
diff --git a/src/pages/names/NamesTable.tsx b/src/pages/names/NamesTable.tsx
index 90feb04..1c9be60 100644
--- a/src/pages/names/NamesTable.tsx
+++ b/src/pages/names/NamesTable.tsx
@@ -11,7 +11,7 @@
import { lookupNames, LookupNamesOptions, LookupNamesResponse } from "@api/lookup";
import {
useMalleablePagination, useTableHistory, useDateColumnWidth
-} from "@utils/table";
+} from "@utils/table/table";
import { useWallets, WalletAddressMap } from "@wallets";
import { NameActions } from "./mgmt/NameActions";
diff --git a/src/pages/names/mgmt/NameActions.tsx b/src/pages/names/mgmt/NameActions.tsx
index 02a7929..b1d5d51 100644
--- a/src/pages/names/mgmt/NameActions.tsx
+++ b/src/pages/names/mgmt/NameActions.tsx
@@ -8,7 +8,7 @@
import { useTFns } from "@utils/i18n";
import { KristName } from "@api/types";
-import { useNameSuffix } from "@utils/currency";
+import { useNameSuffix } from "@utils/krist";
import { useAuth } from "@comp/auth";
import { OpenEditNameFn } from "./NameEditModalLink";
diff --git a/src/pages/names/mgmt/NameEditModal.tsx b/src/pages/names/mgmt/NameEditModal.tsx
index 73a5836..a38e0f6 100644
--- a/src/pages/names/mgmt/NameEditModal.tsx
+++ b/src/pages/names/mgmt/NameEditModal.tsx
@@ -11,7 +11,7 @@
decryptAddresses, DecryptErrorGone, DecryptErrorFailed,
ValidDecryptedAddresses
} from "@wallets";
-import { useNameSuffix } from "@utils/currency";
+import { useNameSuffix } from "@utils/krist";
import { transferNames, updateNames } from "@api/names";
import { useAuthFailedModal } from "@api/AuthFailed";
diff --git a/src/pages/names/mgmt/NamePicker.tsx b/src/pages/names/mgmt/NamePicker.tsx
index 1dd4c19..0b8302d 100644
--- a/src/pages/names/mgmt/NamePicker.tsx
+++ b/src/pages/names/mgmt/NamePicker.tsx
@@ -16,7 +16,7 @@
import { WalletAddressMap, useWallets } from "@wallets";
import { NameOptionGroup, fetchNames } from "./lookupNames";
-import { useNameSuffix } from "@utils/currency";
+import { useNameSuffix } from "@utils/krist";
import shallowEqual from "shallowequal";
import { throttle } from "lodash-es";
diff --git a/src/pages/names/mgmt/NamePurchaseModal.tsx b/src/pages/names/mgmt/NamePurchaseModal.tsx
index d58945f..a88b102 100644
--- a/src/pages/names/mgmt/NamePurchaseModal.tsx
+++ b/src/pages/names/mgmt/NamePurchaseModal.tsx
@@ -16,7 +16,7 @@
import { useWallets, Wallet } from "@wallets";
import {
useNameSuffix, BARE_NAME_REGEX, MAX_NAME_LENGTH, isValidName
-} from "@utils/currency";
+} from "@utils/krist";
import { checkName } from "./checkName";
import { handlePurchaseError } from "./handleErrors";
diff --git a/src/pages/settings/SettingsBackups.tsx b/src/pages/settings/SettingsBackups.tsx
new file mode 100644
index 0000000..4465a1b
--- /dev/null
+++ b/src/pages/settings/SettingsBackups.tsx
@@ -0,0 +1,48 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState } from "react";
+import { Menu } from "antd";
+import {
+ DatabaseOutlined, ImportOutlined, ExportOutlined
+} from "@ant-design/icons";
+
+import { useTFns } from "@utils/i18n";
+
+import { AuthorisedAction } from "@comp/auth/AuthorisedAction";
+import { useMasterPassword } from "@wallets";
+import { ImportBackupModal } from "../backup/ImportBackupModal";
+import { ExportBackupModal } from "../backup/ExportBackupModal";
+
+export function SettingsBackups({ ...props }: any): JSX.Element {
+ const { tStr } = useTFns("settings.");
+
+ const [importVisible, setImportVisible] = useState(false);
+ const [exportVisible, setExportVisible] = useState(false);
+
+ // Used to disable the export button if a master password hasn't been set up
+ const { hasMasterPassword, salt, tester } = useMasterPassword();
+ const allowExport = !!hasMasterPassword && !!salt && !!tester;
+
+ return <>
+ }
+ title={tStr("subMenuBackups")}
+ {...props}
+ >
+
+ setImportVisible(true)}>
+ {tStr("importBackup")}
+
+
+
+ setExportVisible(true)}>
+ {tStr("exportBackup")}
+
+
+
+
+ >;
+}
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
index cc55b68..c34b331 100644
--- a/src/pages/settings/SettingsPage.tsx
+++ b/src/pages/settings/SettingsPage.tsx
@@ -3,7 +3,9 @@
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import { FC } from "react";
import { Menu } from "antd";
-import { BugOutlined, GlobalOutlined, ReloadOutlined, SettingOutlined } from "@ant-design/icons";
+import {
+ BugOutlined, GlobalOutlined, ReloadOutlined, SettingOutlined
+} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -13,6 +15,8 @@
import { SettingBoolean } from "./SettingBoolean";
import { getLanguageItems } from "./translations/LanguageItem";
+import { SettingsBackups } from "./SettingsBackups";
+
import "./SettingsPage.less";
interface SettingsPageLayoutProps extends PageLayoutProps {
@@ -40,6 +44,9 @@
{getLanguageItems()}
+ {/* Backups */}
+
+
{/* Auto-refresh settings */}
();
@@ -198,24 +200,17 @@
order: "DESC"
});
- const { paginationTableProps, hotkeys } = useMalleablePagination(
+ const { paginationTableProps, paginationChange, hotkeys } = useMalleablePagination(
res, res?.transactions,
tKey("tableTotal"),
options, setOptions, setPagination
);
- const dateColumnWidth = useDateColumnWidth();
+ const { walletAddressMap, joinedAddressList } = useWallets();
const highlightOwn = useBooleanSetting("transactionsHighlightOwn") &&
listingType !== ListingType.WALLETS;
const highlightVerified = useBooleanSetting("transactionsHighlightVerified");
- const columns = useMemo(() => getColumns(
- tStr, dateColumnWidth
- ), [tStr, dateColumnWidth]);
-
- // Used to highlight own transactions
- const { walletAddressMap, joinedAddressList } = useWallets();
-
// Fetch the transactions from the API, mapping the table options
useEffect(() => {
debug(
@@ -244,6 +239,61 @@
debug("results? %b res.transactions.length: %d res.count: %d res.total: %d", !!res, res?.transactions?.length, res?.count, res?.total);
+ const renderMobileItem: RenderItem = useCallback(tx => (
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ ), [joinedAddressList]);
+
+ const { isMobile, list } = useMobileList(
+ loading, res?.transactions || [], "id",
+ paginationTableProps.pagination,
+ paginationChange,
+ renderMobileItem
+ );
+
+ return <>
+ {isMobile && list
+ ? list
+ : }
+ {hotkeys}
+ >;
+}
+
+interface DesktopViewProps {
+ loading: boolean;
+ res?: LookupTransactionsResponse;
+
+ paginationTableProps: PaginationTableProps;
+
+ highlightOwn: boolean;
+ highlightVerified: boolean;
+}
+
+function DesktopView({
+ loading, res,
+ paginationTableProps,
+ highlightOwn, highlightVerified
+}: DesktopViewProps): JSX.Element {
+ const { tStr } = useTFns("transactions.");
+
+ const { walletAddressMap, joinedAddressList } = useWallets();
+ const dateColumnWidth = useDateColumnWidth();
+
+ const columns = useMemo(() => getColumns(
+ tStr, dateColumnWidth
+ ), [tStr, dateColumnWidth]);
+
const getRowClasses = useCallback((tx: KristTransaction): string => {
return classNames({
// Highlight own transactions
@@ -258,7 +308,7 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightOwn, highlightVerified, joinedAddressList]);
- const tbl =
+ return
className="transactions-table"
size="small"
scroll={{ x: true }}
@@ -274,9 +324,4 @@
columns={columns}
/>;
-
- return <>
- {tbl}
- {hotkeys}
- >;
}
diff --git a/src/pages/transactions/send/AmountInput.tsx b/src/pages/transactions/send/AmountInput.tsx
index 83d508a..afb1aee 100644
--- a/src/pages/transactions/send/AmountInput.tsx
+++ b/src/pages/transactions/send/AmountInput.tsx
@@ -6,7 +6,7 @@
import { useTranslation } from "react-i18next";
import { useWallets } from "@wallets";
-import { useCurrency } from "@utils/currency";
+import { useCurrency } from "@utils/krist";
import { KristSymbol } from "@comp/krist/KristSymbol";
diff --git a/src/pages/transactions/send/SendTransactionForm.tsx b/src/pages/transactions/send/SendTransactionForm.tsx
index a769c58..be8673a 100644
--- a/src/pages/transactions/send/SendTransactionForm.tsx
+++ b/src/pages/transactions/send/SendTransactionForm.tsx
@@ -14,7 +14,7 @@
import { setLastTxFrom } from "@actions/WalletsActions";
import { useWallets, Wallet } from "@wallets";
-import { useMountEffect } from "@utils";
+import { useMountEffect } from "@utils/hooks";
import { sha256 } from "@utils/crypto";
import { useBooleanSetting, useIntegerSetting } from "@utils/settings";
diff --git a/src/pages/wallets/AddWalletModal.tsx b/src/pages/wallets/AddWalletModal.tsx
index ffd46aa..28e5af5 100644
--- a/src/pages/wallets/AddWalletModal.tsx
+++ b/src/pages/wallets/AddWalletModal.tsx
@@ -7,8 +7,8 @@
import { useTranslation, Trans } from "react-i18next";
-import { generatePassword } from "@utils";
-import { useAddressPrefix } from "@utils/currency";
+import { generatePassword } from "@utils/crypto";
+import { useAddressPrefix } from "@utils/krist";
import { FakeUsernameInput } from "@comp/auth/FakeUsernameInput";
import { CopyInputButton } from "@comp/CopyInputButton";
diff --git a/src/pages/wallets/ManageBackupsDropdown.tsx b/src/pages/wallets/ManageBackupsDropdown.tsx
index 2419468..8393cff 100644
--- a/src/pages/wallets/ManageBackupsDropdown.tsx
+++ b/src/pages/wallets/ManageBackupsDropdown.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { Dispatch, SetStateAction} from "react";
+import { Dispatch, SetStateAction } from "react";
import { Button, Dropdown, Menu } from "antd";
import {
DatabaseOutlined, DownOutlined, ImportOutlined, ExportOutlined
diff --git a/src/pages/wallets/WalletsTable.tsx b/src/pages/wallets/WalletsTable.tsx
index 90f45f5..b2737f0 100644
--- a/src/pages/wallets/WalletsTable.tsx
+++ b/src/pages/wallets/WalletsTable.tsx
@@ -18,7 +18,7 @@
import { OpenWalletInfoFn } from "./info/WalletInfoModal";
import { keyedNullSort } from "@utils";
-import { useDateColumnWidth } from "@utils/table";
+import { useDateColumnWidth } from "@utils/table/table";
interface Props {
openEditWallet: OpenEditWalletFn;
diff --git a/src/pages/whatsnew/WhatsNewPage.tsx b/src/pages/whatsnew/WhatsNewPage.tsx
index 5e89abe..17bebee 100644
--- a/src/pages/whatsnew/WhatsNewPage.tsx
+++ b/src/pages/whatsnew/WhatsNewPage.tsx
@@ -12,7 +12,7 @@
import * as api from "@api";
import { WhatsNewResponse, Commit } from "./types";
-import { getAuthorInfo } from "@utils/credits";
+import { getAuthorInfo } from "@utils";
import { PageLayout } from "@layout/PageLayout";
diff --git a/src/style/components.less b/src/style/components.less
index d98ad2a..9817313 100644
--- a/src/style/components.less
+++ b/src/style/components.less
@@ -37,12 +37,16 @@
@media (max-width: @screen-md) {
// Make the menu full-width on mobile
width: 100vw;
- margin: 0 -@padding-lg;
+ margin: 0 -@padding-md;
.ant-menu-sub > .ant-menu-item {
padding-left: @padding-sm !important;
}
}
+
+ @media (max-width: @screen-sm) {
+ margin: 0 -@padding-sm;
+ }
}
.ant-btn {
diff --git a/src/style/table.less b/src/style/table.less
new file mode 100644
index 0000000..5e344e3
--- /dev/null
+++ b/src/style/table.less
@@ -0,0 +1,43 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+@import (reference) "../App.less";
+
+.table-mobile-list-view {
+ .ant-list-items .card-list-item {
+ padding: @padding-sm @padding-md;
+ margin-bottom: 0;
+
+ border-bottom: 1px solid @border-color-split;
+ }
+
+ .ant-list-pagination {
+ display: grid;
+ justify-content: center;
+
+ margin-bottom: @margin-lg;
+
+ .ant-pagination-total-text {
+ display: block;
+ text-align: center;
+ }
+
+ .ant-pagination-item, .ant-pagination-prev {
+ margin-right: 3px;
+ }
+
+ .ant-pagination-next {
+ margin-right: 0;
+ }
+ }
+
+ @media (max-width: @screen-md) {
+ // Make the list full-width
+ width: 100vw;
+ margin: 0 -@padding-md;
+ }
+
+ @media (max-width: @screen-sm) {
+ margin: 0 -@padding-sm;
+ }
+}
diff --git a/src/utils/CryptoJS.ts b/src/utils/CryptoJS.ts
deleted file mode 100644
index f5068b0..0000000
--- a/src/utils/CryptoJS.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-
-/** This file contains a polyfill for CryptoJS AES decryption and password
- * derivation function. */
-
-import { MD5 } from "spu-md5";
-import base64 from "base64-arraybuffer";
-
-interface EvpKey {
- key: Uint8Array;
- iv: Uint8Array;
- cryptoKey: CryptoKey;
-}
-
-/**
- * Derive an AES key using {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}.
- * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES
.
- *
- * Links:
- * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js}
- *
- * Implementation mostly sourced from:
- * {@link https://stackoverflow.com/a/27250883/1499974}
- * {@link https://stackoverflow.com/a/52598588/1499974}
- * {@link https://stackoverflow.com/a/29152379/1499974}
- *
- * @param password - The bytes of the password used for key derivation.
- * @param keySize - The number of bytes used for the key.
- * @param ivSize - The number of bytes used for the IV.
- * @param salt - The salt used for key derivation.
- * @param iterations - The number of iterations used.
- * @returns The key and IV (Uint8Array) derived from the password.
- */
-async function evpKDF(password: Uint8Array, keySize: number, ivSize: number,
- salt: Uint8Array, iterations: number): Promise {
- const targetKeySize = keySize + ivSize;
- const derivedBytes = new Uint8Array(targetKeySize * 4);
-
- let numberOfDerivedWords = 0;
- let block: Uint8Array | null = null;
- let md5 = new MD5();
-
- while (numberOfDerivedWords < targetKeySize) {
- for (let i = 0; i < iterations; i++) {
- if (block !== null) md5.update(block);
-
- if (i === 0) { // hash the password and salt on the first iteration only
- md5.update(password);
- md5.update(salt);
- }
-
- block = md5.toUint8Array();
- md5 = new MD5();
- }
-
- if (block === null)
- throw new Error("EvpKDF block is null!");
-
- const blockLength = Math.min(block.length, (targetKeySize - numberOfDerivedWords) * 4);
- derivedBytes.set(block.subarray(0, blockLength), numberOfDerivedWords * 4);
-
- numberOfDerivedWords += block.length / 4;
- }
-
- // get the key from the first 32 bytes, then the IV from the next 32
- const key = derivedBytes.subarray(0, keySize * 4);
- const iv = derivedBytes.subarray(keySize * 4, (keySize * 4) + (ivSize * 4));
-
- // create a SubtleCrypto CryptoKey with the given raw key bytes (for AES-CBC encrypt/decrypt)
- const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-CBC", true, ["encrypt", "decrypt"]);
-
- return { key, iv, cryptoKey };
-}
-
-/**
- * Decrypt using AES-CBC and {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}.
- * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES.decrypt
.
- *
- * Links:
- * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js}
- * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js}
- *
- * Implementation mostly sourced from:
- * {@link https://stackoverflow.com/a/27250883/1499974}
- * {@link https://stackoverflow.com/a/52598588/1499974}
- * {@link https://stackoverflow.com/a/29152379/1499974}
- *
- * @param encrypted - The base64-encoded encrypted data.
- * @param password - The password used to decrypt the data.
- * @returns The decrypted data.
- */
-export async function decryptCryptoJS(encrypted: string, password: string): Promise {
- const encoder = new TextEncoder();
- const decoder = new TextDecoder();
-
- const keySize = 256 / 32;
- const ivSize = 128 / 32;
-
- // Decode the encrypted base64 string to get the raw bytes
- const cipherText = new Uint8Array(base64.decode(encrypted));
-
- // Check if the cipher text begins with "Salted__":
- const prefix = new Uint32Array(cipherText.buffer, 0, 2);
- const salted = prefix[0] === 0x746c6153 && prefix[1] === 0x5f5f6465;
-
- // Fetch the salt from the cipher text, if necessary, and get the actual cipher text
- const salt = cipherText.subarray(8, 16);
- const actualCipherText = salted ? cipherText.subarray(16, cipherText.length) : cipherText;
-
- // Derive the key and IV
- const { cryptoKey, iv } = await evpKDF(encoder.encode(password), keySize, ivSize, salt, 1);
-
- // Decrypt the data
- const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, actualCipherText);
- return decoder.decode(decrypted); // decode Uint8Array to UTF-8
-}
diff --git a/src/utils/commonmeta.ts b/src/utils/commonmeta.ts
deleted file mode 100644
index 5863a5b..0000000
--- a/src/utils/commonmeta.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { getNameParts } from "./currency";
-
-export interface CommonMeta {
- metaname?: string;
- name?: string;
- recipient?: string;
-
- return?: string;
- returnMetaname?: string;
- returnName?: string;
- returnRecipient?: string;
-
- custom: Record;
-}
-
-export function parseCommonMeta(
- nameSuffix: string,
- metadata: string | undefined | null
-): CommonMeta | null {
- if (!metadata) return null;
-
- const custom: Record = {};
- const out: CommonMeta = { custom };
-
- const metaParts = metadata.split(";");
- if (metaParts.length <= 0) return null;
-
- const nameParts = getNameParts(nameSuffix, metaParts[0]);
- if (nameParts) {
- out.metaname = nameParts.metaname;
- out.name = nameParts.nameWithSuffix;
-
- out.recipient = nameParts.metaname
- ? nameParts.metaname + "@" + nameParts.nameWithSuffix
- : nameParts.nameWithSuffix;
- }
-
- for (let i = 0; i < metaParts.length; i++) {
- const metaPart = metaParts[i];
- const kv = metaPart.split("=", 2);
-
- if (i === 0 && nameParts) continue;
-
- if (kv.length === 1) {
- custom[i.toString()] = kv[0];
- } else {
- custom[kv[0]] = kv.slice(1).join("=");
- }
- }
-
- const rawReturn = out.return = custom.return;
- if (rawReturn) {
- const returnParts = getNameParts(nameSuffix, rawReturn);
- if (returnParts) {
- out.returnMetaname = returnParts.metaname;
- out.returnName = returnParts.nameWithSuffix;
-
- out.returnRecipient = returnParts.metaname
- ? returnParts.metaname + "@" + returnParts.nameWithSuffix
- : returnParts.nameWithSuffix;
- }
- }
-
- return out;
-}
diff --git a/src/utils/credits.ts b/src/utils/credits.ts
deleted file mode 100644
index 4809bf1..0000000
--- a/src/utils/credits.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { useState } from "react";
-import { useMountEffect } from "./";
-import packageJson from "../../package.json";
-
-export function getAuthorInfo(): { authorName: string; authorURL: string; gitURL: string } {
- const authorName = packageJson.author || "Lemmmy";
- const authorURL = `https://github.com/${authorName}`;
- const gitURL = packageJson.repository.url.replace(/\.git$/, "");
-
- return { authorName, authorURL, gitURL };
-}
-
-export interface HostInfo {
- host: {
- name: string;
- url: string;
- };
-}
-
-export function useHostInfo(): HostInfo | undefined {
- const [host, setHost] = useState();
-
- useMountEffect(() => {
- (async () => {
- try {
- // Add the host information if host.json exists
- const hostFile = "host-attribution"; // Trick webpack into dynamic importing
- const hostData = await import("../__data__/" + hostFile + ".json");
- setHost(hostData);
- } catch (ignored) {
- // Ignored
- }
- })();
- });
-
- return host;
-}
diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts
deleted file mode 100644
index 9c13d01..0000000
--- a/src/utils/crypto.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import base64 from "base64-arraybuffer";
-
-import { toHex, fromHex } from "./";
-
-// -----------------------------------------------------------------------------
-// SHA256
-// -----------------------------------------------------------------------------
-
-/**
- * Utility function to return the hexadecimal SHA-256 digest of an input string.
- *
- * @param input - The input string to hash.
- * @returns The hexadecimal SHA-256 digest of input
.
- */
-export async function sha256(input: string): Promise {
- const inputUtf8 = new TextEncoder().encode(input); // Convert input to UTF-8
- return toHex(await crypto.subtle.digest("SHA-256", inputUtf8));
-}
-
-/**
- * Utility function to return the double hexadecimal SHA-256 digest of an input string.
- * This is equivalent to sha256(sha256(input))
.
- *
- * @param input - The input string to hash.
- * @returns The double hexadecimal SHA-256 digest of input
.
- */
-export async function doubleSHA256(input: string): Promise {
- return sha256(await sha256(input));
-}
-
-// -----------------------------------------------------------------------------
-// AES-GCM
-// -----------------------------------------------------------------------------
-export type AESEncryptedString = string;
-
-/**
- * Encrypts the given input string with the AES-GCM cipher, deriving a key from
- * the given password with SHA-256.
- *
- * Implementation mostly sourced from:
- * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a}
- *
- * @param input - The plain text input to be encrypted.
- * @param password - The password used to encrypt the input data.
- * @returns The encrypted cipher text (`IV (12 bytes hex) + CT (base64)`)
- */
-export async function aesGcmEncrypt(input: string, password: string): Promise {
- // Hash the password as UTF-8
- const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password));
-
- // Generate a 96-bit random IV
- const iv = crypto.getRandomValues(new Uint8Array(12));
-
- // Generate the key from the password
- const algorithm = { name: "AES-GCM", iv };
- const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["encrypt"]);
-
- // Encrypt the UTF-8-encoded input
- const cipherText = await crypto.subtle.encrypt(algorithm, key, new TextEncoder().encode(input));
-
- // Return the IV (as hex) + cipher text (as base64) together
- return toHex(iv) + base64.encode(cipherText);
-}
-
-/**
- * Decrypts the given input cipher text with the AES-GCM cipher, deriving a key
- * from the given password with SHA-256. The input must be of the form
- * `IV (12 bytes hex) + CT (base64)`.
- *
- * Implementation mostly sourced from:
- * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a}
- *
- * @param input - The IV and cipher text to decrypt.
- * @param password - The password used to decrypt the input cipher text.
- * @returns The decrypted plain text data.
- */
-export async function aesGcmDecrypt(input: AESEncryptedString, password: string): Promise {
- // Hash the password as UTF-8
- const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password));
-
- // Get the IV from the encrypted string (first 96 bits/12 bytes/24 hex chars)
- const iv = fromHex(input.slice(0, 24));
-
- // Generate the key from the password
- const algorithm = { name: "AES-GCM", iv };
- const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["decrypt"]);
-
- // Decode the base64 cipher text to a UTF-8 Uint8Array
- const cipherText = base64.decode(input.slice(24));
-
- // Decrypt the cipher text
- const dec = await crypto.subtle.decrypt(algorithm, key, cipherText);
-
- // Decode from UTF-8
- return new TextDecoder().decode(dec);
-}
-
-// -----------------------------------------------------------------------------
-// CryptoJS
-// -----------------------------------------------------------------------------
-
-/** Polyfill for decrypting CryptoJS AES strings. This is used to migrate
- * local storage from KristWeb v1. */
-export { decryptCryptoJS } from "./CryptoJS";
diff --git a/src/utils/crypto/CryptoJS.ts b/src/utils/crypto/CryptoJS.ts
new file mode 100644
index 0000000..f5068b0
--- /dev/null
+++ b/src/utils/crypto/CryptoJS.ts
@@ -0,0 +1,123 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+
+/** This file contains a polyfill for CryptoJS AES decryption and password
+ * derivation function. */
+
+import { MD5 } from "spu-md5";
+import base64 from "base64-arraybuffer";
+
+interface EvpKey {
+ key: Uint8Array;
+ iv: Uint8Array;
+ cryptoKey: CryptoKey;
+}
+
+/**
+ * Derive an AES key using {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}.
+ * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES
.
+ *
+ * Links:
+ * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js}
+ *
+ * Implementation mostly sourced from:
+ * {@link https://stackoverflow.com/a/27250883/1499974}
+ * {@link https://stackoverflow.com/a/52598588/1499974}
+ * {@link https://stackoverflow.com/a/29152379/1499974}
+ *
+ * @param password - The bytes of the password used for key derivation.
+ * @param keySize - The number of bytes used for the key.
+ * @param ivSize - The number of bytes used for the IV.
+ * @param salt - The salt used for key derivation.
+ * @param iterations - The number of iterations used.
+ * @returns The key and IV (Uint8Array) derived from the password.
+ */
+async function evpKDF(password: Uint8Array, keySize: number, ivSize: number,
+ salt: Uint8Array, iterations: number): Promise {
+ const targetKeySize = keySize + ivSize;
+ const derivedBytes = new Uint8Array(targetKeySize * 4);
+
+ let numberOfDerivedWords = 0;
+ let block: Uint8Array | null = null;
+ let md5 = new MD5();
+
+ while (numberOfDerivedWords < targetKeySize) {
+ for (let i = 0; i < iterations; i++) {
+ if (block !== null) md5.update(block);
+
+ if (i === 0) { // hash the password and salt on the first iteration only
+ md5.update(password);
+ md5.update(salt);
+ }
+
+ block = md5.toUint8Array();
+ md5 = new MD5();
+ }
+
+ if (block === null)
+ throw new Error("EvpKDF block is null!");
+
+ const blockLength = Math.min(block.length, (targetKeySize - numberOfDerivedWords) * 4);
+ derivedBytes.set(block.subarray(0, blockLength), numberOfDerivedWords * 4);
+
+ numberOfDerivedWords += block.length / 4;
+ }
+
+ // get the key from the first 32 bytes, then the IV from the next 32
+ const key = derivedBytes.subarray(0, keySize * 4);
+ const iv = derivedBytes.subarray(keySize * 4, (keySize * 4) + (ivSize * 4));
+
+ // create a SubtleCrypto CryptoKey with the given raw key bytes (for AES-CBC encrypt/decrypt)
+ const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-CBC", true, ["encrypt", "decrypt"]);
+
+ return { key, iv, cryptoKey };
+}
+
+/**
+ * Decrypt using AES-CBC and {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation|EvpKDF}.
+ * Uses a single iteration and MD5 for hashing. Designed to be compatible with CryptoJS.AES.decrypt
.
+ *
+ * Links:
+ * {@link https://wiki.openssl.org/index.php/EVP_Key_Derivation}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/evpkdf.js}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/cipher-core.js}
+ * {@link https://github.com/brix/crypto-js/blob/develop/src/aes.js}
+ *
+ * Implementation mostly sourced from:
+ * {@link https://stackoverflow.com/a/27250883/1499974}
+ * {@link https://stackoverflow.com/a/52598588/1499974}
+ * {@link https://stackoverflow.com/a/29152379/1499974}
+ *
+ * @param encrypted - The base64-encoded encrypted data.
+ * @param password - The password used to decrypt the data.
+ * @returns The decrypted data.
+ */
+export async function decryptCryptoJS(encrypted: string, password: string): Promise {
+ const encoder = new TextEncoder();
+ const decoder = new TextDecoder();
+
+ const keySize = 256 / 32;
+ const ivSize = 128 / 32;
+
+ // Decode the encrypted base64 string to get the raw bytes
+ const cipherText = new Uint8Array(base64.decode(encrypted));
+
+ // Check if the cipher text begins with "Salted__":
+ const prefix = new Uint32Array(cipherText.buffer, 0, 2);
+ const salted = prefix[0] === 0x746c6153 && prefix[1] === 0x5f5f6465;
+
+ // Fetch the salt from the cipher text, if necessary, and get the actual cipher text
+ const salt = cipherText.subarray(8, 16);
+ const actualCipherText = salted ? cipherText.subarray(16, cipherText.length) : cipherText;
+
+ // Derive the key and IV
+ const { cryptoKey, iv } = await evpKDF(encoder.encode(password), keySize, ivSize, salt, 1);
+
+ // Decrypt the data
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, actualCipherText);
+ return decoder.decode(decrypted); // decode Uint8Array to UTF-8
+}
diff --git a/src/utils/crypto/crypto.ts b/src/utils/crypto/crypto.ts
new file mode 100644
index 0000000..bc9f74a
--- /dev/null
+++ b/src/utils/crypto/crypto.ts
@@ -0,0 +1,107 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import base64 from "base64-arraybuffer";
+
+import { toHex, fromHex } from "..";
+
+// -----------------------------------------------------------------------------
+// SHA256
+// -----------------------------------------------------------------------------
+
+/**
+ * Utility function to return the hexadecimal SHA-256 digest of an input string.
+ *
+ * @param input - The input string to hash.
+ * @returns The hexadecimal SHA-256 digest of input
.
+ */
+export async function sha256(input: string): Promise {
+ const inputUtf8 = new TextEncoder().encode(input); // Convert input to UTF-8
+ return toHex(await crypto.subtle.digest("SHA-256", inputUtf8));
+}
+
+/**
+ * Utility function to return the double hexadecimal SHA-256 digest of an input string.
+ * This is equivalent to sha256(sha256(input))
.
+ *
+ * @param input - The input string to hash.
+ * @returns The double hexadecimal SHA-256 digest of input
.
+ */
+export async function doubleSHA256(input: string): Promise {
+ return sha256(await sha256(input));
+}
+
+// -----------------------------------------------------------------------------
+// AES-GCM
+// -----------------------------------------------------------------------------
+export type AESEncryptedString = string;
+
+/**
+ * Encrypts the given input string with the AES-GCM cipher, deriving a key from
+ * the given password with SHA-256.
+ *
+ * Implementation mostly sourced from:
+ * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a}
+ *
+ * @param input - The plain text input to be encrypted.
+ * @param password - The password used to encrypt the input data.
+ * @returns The encrypted cipher text (`IV (12 bytes hex) + CT (base64)`)
+ */
+export async function aesGcmEncrypt(input: string, password: string): Promise {
+ // Hash the password as UTF-8
+ const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password));
+
+ // Generate a 96-bit random IV
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+
+ // Generate the key from the password
+ const algorithm = { name: "AES-GCM", iv };
+ const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["encrypt"]);
+
+ // Encrypt the UTF-8-encoded input
+ const cipherText = await crypto.subtle.encrypt(algorithm, key, new TextEncoder().encode(input));
+
+ // Return the IV (as hex) + cipher text (as base64) together
+ return toHex(iv) + base64.encode(cipherText);
+}
+
+/**
+ * Decrypts the given input cipher text with the AES-GCM cipher, deriving a key
+ * from the given password with SHA-256. The input must be of the form
+ * `IV (12 bytes hex) + CT (base64)`.
+ *
+ * Implementation mostly sourced from:
+ * {@link https://gist.github.com/chrisveness/43bcda93af9f646d083fad678071b90a}
+ *
+ * @param input - The IV and cipher text to decrypt.
+ * @param password - The password used to decrypt the input cipher text.
+ * @returns The decrypted plain text data.
+ */
+export async function aesGcmDecrypt(input: AESEncryptedString, password: string): Promise {
+ // Hash the password as UTF-8
+ const passwordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password));
+
+ // Get the IV from the encrypted string (first 96 bits/12 bytes/24 hex chars)
+ const iv = fromHex(input.slice(0, 24));
+
+ // Generate the key from the password
+ const algorithm = { name: "AES-GCM", iv };
+ const key = await crypto.subtle.importKey("raw", passwordHash, algorithm, false, ["decrypt"]);
+
+ // Decode the base64 cipher text to a UTF-8 Uint8Array
+ const cipherText = base64.decode(input.slice(24));
+
+ // Decrypt the cipher text
+ const dec = await crypto.subtle.decrypt(algorithm, key, cipherText);
+
+ // Decode from UTF-8
+ return new TextDecoder().decode(dec);
+}
+
+// -----------------------------------------------------------------------------
+// CryptoJS
+// -----------------------------------------------------------------------------
+
+/** Polyfill for decrypting CryptoJS AES strings. This is used to migrate
+ * local storage from KristWeb v1. */
+export { decryptCryptoJS } from "./CryptoJS";
diff --git a/src/utils/crypto/generatePassword.ts b/src/utils/crypto/generatePassword.ts
new file mode 100644
index 0000000..1be8005
--- /dev/null
+++ b/src/utils/crypto/generatePassword.ts
@@ -0,0 +1,24 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+/**
+ * Generates a secure random password based on a length and character set.
+ *
+ * Implementation mostly sourced from: {@link https://stackoverflow.com/a/51540480/1499974}
+ *
+ * See also: {@link https://github.com/chancejs/chancejs/issues/232#issuecomment-182500222}
+ *
+ * @param length - The desired length of the password.
+ * @param charset - A string containing all the characters the password may
+ * contain.
+*/
+export function generatePassword(
+ length = 32,
+ charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-"
+): string {
+ // NOTE: talk about correctness with modulo and its bias (the charset is 64
+ // characters right now anyway)
+ return Array.from(crypto.getRandomValues(new Uint32Array(length)))
+ .map(x => charset[x % charset.length])
+ .join("");
+}
diff --git a/src/utils/crypto/index.ts b/src/utils/crypto/index.ts
new file mode 100644
index 0000000..fd61cb7
--- /dev/null
+++ b/src/utils/crypto/index.ts
@@ -0,0 +1,6 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+export * from "./crypto";
+export * from "./CryptoJS";
+export * from "./generatePassword";
diff --git a/src/utils/currency.ts b/src/utils/currency.ts
deleted file mode 100644
index feaff7c..0000000
--- a/src/utils/currency.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { useSelector } from "react-redux";
-import { RootState } from "@store";
-
-import { KristCurrency } from "@api/types";
-
-import { memoize, escapeRegExp, truncate, toString } from "lodash-es";
-
-// -----------------------------------------------------------------------------
-// NAMES
-// -----------------------------------------------------------------------------
-export const BARE_NAME_REGEX = /^([a-z0-9]{1,64})$/;
-export const MAX_NAME_LENGTH = 64;
-export const isValidName =
- (name: string): boolean => BARE_NAME_REGEX.test(name);
-
-// Cheap way to avoid RegExp DoS
-const MAX_NAME_SUFFIX_LENGTH = 6;
-const _cleanNameSuffix = (nameSuffix: string | undefined | null): string => {
- // Ensure the name suffix is safe to put into a RegExp
- const stringSuffix = toString(nameSuffix);
- const shortSuffix = truncate(stringSuffix, { length: MAX_NAME_SUFFIX_LENGTH, omission: "" });
- const escaped = escapeRegExp(shortSuffix);
- return escaped;
-};
-export const cleanNameSuffix = memoize(_cleanNameSuffix);
-
-const _getNameRegex = (nameSuffix: string | undefined | null, metadata?: boolean): RegExp =>
- new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64})(\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`);
-export const getNameRegex = memoize(_getNameRegex);
-
-export interface NameParts {
- metaname?: string;
- name?: string;
- nameSuffix?: string;
- nameWithSuffix?: string;
- recipient?: string;
-}
-export function getNameParts(
- nameSuffix: string | undefined | null,
- name: string | undefined
-): NameParts | undefined {
- if (!nameSuffix || !name) return;
-
- const nameMatches = getNameRegex(nameSuffix).exec(name);
- if (!nameMatches) return undefined;
-
- const mMetaname = nameMatches[1] || undefined;
- const mName = nameMatches[2] || undefined;
- const nameWithSuffix = mName ? mName + "." + nameSuffix : undefined;
- const recipient = mMetaname
- ? mMetaname + "@" + nameWithSuffix
- : nameWithSuffix;
-
- return {
- metaname: mMetaname,
- name: mName,
- nameSuffix,
- nameWithSuffix,
- recipient
- };
-}
-
-const _stripNameSuffixRegExp = (nameSuffix: string | undefined | null): RegExp =>
- new RegExp(`\\.${cleanNameSuffix(nameSuffix)}$`);
-export const stripNameSuffixRegExp = memoize(_stripNameSuffixRegExp);
-
-export const stripNameSuffix = (nameSuffix: string | undefined | null, inp: string): string =>
- inp.replace(stripNameSuffixRegExp(nameSuffix), "");
-
-export const stripNameFromMetadata = (nameSuffix: string | undefined | null, metadata: string): string =>
- metadata.replace(getNameRegex(nameSuffix, true), "");
-
-// -----------------------------------------------------------------------------
-// ADDRESSES
-// -----------------------------------------------------------------------------
-const MAX_ADDRESS_PREFIX_LENGTH = 1;
-const _cleanAddressPrefix = (addressPrefix: string | undefined | null): string => {
- // This might be slightly cursed when the max prefix length is 1 character,
- // but let's call it future-proofing.
- const stringPrefix = toString(addressPrefix);
- const shortPrefix = truncate(stringPrefix, { length: MAX_ADDRESS_PREFIX_LENGTH, omission: "" });
- const escaped = escapeRegExp(shortPrefix);
- return escaped;
-};
-export const cleanAddressPrefix = memoize(_cleanAddressPrefix);
-
-// Supports v1 addresses too
-const _getAddressRegex = (addressPrefix: string | undefined | null): RegExp =>
- new RegExp(`^(?:${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}|[a-f0-9]{10})$`);
-export const getAddressRegex = memoize(_getAddressRegex);
-
-// Only supports v2 addresses
-const _getAddressRegexV2 = (addressPrefix: string | undefined | null): RegExp =>
- new RegExp(`^${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}$`);
-export const getAddressRegexV2 = memoize(_getAddressRegexV2);
-
-/**
- * Returns whether or not an address is a valid Krist address for the current
- * sync node.
- *
- * @param addressPrefix - The single-character address prefix provided by the
- * sync node.
- * @param address - The address to check for validity.
- * @param allowV1 - Whether or not the function should validate v1 addresses.
- * Note that as of February 2021, the Krist server no longer accepts
- * any kind of transaction to/from a v1 address, so features that are
- * validating an address for purpose of a transaction (e.g. the address
- * picker) should NOT set this to true.
- */
-export function isValidAddress(
- addressPrefix: string | undefined | null,
- address: string,
- allowV1?: boolean
-): boolean {
- return allowV1
- ? getAddressRegex(addressPrefix).test(address)
- : getAddressRegexV2(addressPrefix).test(address);
-}
-
-export const v1AddressRegex = /^[a-f0-9]{10}$/;
-export const isV1Address = (address: string): boolean =>
- v1AddressRegex.test(address);
-
-// -----------------------------------------------------------------------------
-// MISC
-// -----------------------------------------------------------------------------
-
-/**
- * Estimates the network mining hash-rate, returning it as a formatted string.
- *
- * TODO: Some people claimed they had a more accurate function for this. PRs
- * welcome!
- *
- * @param work - The current block difficulty.
- * @param secondsPerBlock - The number of seconds per block, as per the sync
- * node's configuration.
-*/
-export function estimateHashRate(work: number, secondsPerBlock: number): string {
- // Identical to the function from KristWeb 1
- const rate = 1 / (work / (Math.pow(256, 6)) * secondsPerBlock);
- if (rate === 0) return "0 H/s";
-
- const sizes = ["H", "KH", "MH", "GH", "TH"];
- const i = Math.min(Math.floor(Math.log(rate) / Math.log(1000)), sizes.length);
- return parseFloat((rate / Math.pow(1000, i)).toFixed(2)) + " " + sizes[i] + "/s";
-}
-
-/** Hook to get the address prefix. */
-export const useAddressPrefix = (): string =>
- useSelector((s: RootState) => s.node.currency.address_prefix);
-
-/** Hook to get the name suffix. */
-export const useNameSuffix = (): string =>
- useSelector((s: RootState) => s.node.currency.name_suffix);
-
-/** Hook to get all the currency values. */
-export const useCurrency = (): KristCurrency =>
- useSelector((s: RootState) => s.node.currency);
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
new file mode 100644
index 0000000..76c7c91
--- /dev/null
+++ b/src/utils/hooks/index.ts
@@ -0,0 +1,6 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+export * from "./useBreakpoint";
+export * from "./useHistoryState";
+export * from "./useMountEffect";
diff --git a/src/utils/hooks/useBreakpoint.ts b/src/utils/hooks/useBreakpoint.ts
new file mode 100644
index 0000000..8fcdda8
--- /dev/null
+++ b/src/utils/hooks/useBreakpoint.ts
@@ -0,0 +1,95 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+// -----------------------------------------------------------------------------
+// This is essentially a combination of ant's useBreakpoint hook and
+// responsiveObserve, except that it will ALWAYS return the matches. Ant's
+// current implementation will return `undefined` for the breakpoints on first
+// render, while it waits for a subscription to be provided. This causes a lot
+// of unnecessary double renders, and in some cases can even cause errors.
+//
+// This file is based off of the following source code from ant-design, which is
+// licensed under the MIT license:
+//
+// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/grid/hooks/useBreakpoint.tsx
+// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/_util/responsiveObserve.ts
+//
+// For the full terms of the MIT license used by ant-design, see:
+// https://github.com/ant-design/ant-design/blob/master/LICENSE
+// -----------------------------------------------------------------------------
+import ResponsiveObserve, { Breakpoint, responsiveMap } from "antd/lib/_util/responsiveObserve";
+import { useEffect, useState, useRef } from "react";
+
+import { shallowEqual } from "fast-equals";
+
+export type ScreenMap = Record;
+type SubscribeFn = (screens: ScreenMap) => void;
+
+let screenCache: ScreenMap;
+
+const NewResponsiveObserve = {
+ ...ResponsiveObserve,
+
+ getInitialValues(): ScreenMap {
+ if (screenCache) return screenCache;
+
+ // Get the initial values for the media queries if we don't already have
+ // them.
+ screenCache = {} as ScreenMap;
+
+ let bp: Breakpoint;
+ for (bp in responsiveMap) {
+ const query = responsiveMap[bp];
+ const mql = window.matchMedia(query);
+ screenCache[bp] = !!mql.matches;
+ }
+
+ return screenCache;
+ },
+
+ subscribe(fn: SubscribeFn): number {
+ // Get the current values, to fill in any 'undefined' values that may arise
+ // from the original responsive listener.
+ const initialValues = NewResponsiveObserve.getInitialValues();
+
+ const token = ResponsiveObserve.subscribe(screenMap => {
+ // The object gets instantiated in the definition of `listener`, so
+ // mutating it here is okay. Override any undefined/missing screen entries
+ // (not that this should ever happen, but the types act like it can) with
+ // the initial values we got from baseResponses.
+ let bp: Breakpoint;
+ for (bp in responsiveMap) {
+ if (screenMap[bp] === undefined)
+ screenMap[bp] = initialValues[bp];
+ }
+
+ fn(screenMap as ScreenMap);
+ });
+
+ return token;
+ }
+};
+
+// Similar to Grid.useBreakpoint, except it will never return any undefined
+// values, and it keeps track of breakpoint equality, so it won't perform any
+// unnecessary re-renders when the listeners populate the screen map.
+export function useBreakpoint(): ScreenMap {
+ const initialValues = NewResponsiveObserve.getInitialValues();
+ const [screens, setScreens] = useState(initialValues);
+ const lastScreens = useRef(initialValues);
+
+ useEffect(() => {
+ const token = NewResponsiveObserve.subscribe(screenMap => {
+ // Only update the state (triggering re-renders) if the breakpoints
+ // actually changed
+ if (!shallowEqual(lastScreens.current, screenMap)) {
+ setScreens(screenMap);
+ lastScreens.current = screenMap;
+ }
+ });
+
+ return () => NewResponsiveObserve.unsubscribe(token);
+ }, []);
+
+ return screens;
+}
diff --git a/src/utils/hooks/useHistoryState.ts b/src/utils/hooks/useHistoryState.ts
new file mode 100644
index 0000000..c8ddc89
--- /dev/null
+++ b/src/utils/hooks/useHistoryState.ts
@@ -0,0 +1,55 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState } from "react";
+import { useHistory, useLocation } from "react-router-dom";
+
+import Debug from "debug";
+const debug = Debug("kristweb:useHistoryState");
+
+/**
+ * Wrapper for useState that saves its value in the browser history stack
+ * as location state. Note that this doesn't yet support computed state.
+ *
+ * The state's value must be serialisable, and less than 2 MiB.
+ *
+ * @param initialState - The initial value of the state.
+ * @param stateKey - The key by which to store the state's value in the history
+ * stack.
+ */
+export function useHistoryState(
+ initialState: S,
+ stateKey: string
+): [S, (s: S) => void] {
+ const history = useHistory();
+ const location = useLocation>>();
+
+ const [state, setState] = useState(
+ location?.state?.[stateKey] ?? initialState
+ );
+
+ // Wraps setState to update the stored state value and replace the entry on
+ // the history stack (via `updateLocation`).
+ function wrappedSetState(newState: S): void {
+ debug("useHistoryState: setting state %s to %o", stateKey, newState);
+ updateLocation(newState);
+ setState(newState);
+ }
+
+ // Merge the new state into the location state (using stateKey) and replace
+ // the entry on the history stack.
+ function updateLocation(newState: S) {
+ const updatedLocation = {
+ ...location,
+ state: {
+ ...location?.state,
+ [stateKey]: newState
+ }
+ };
+
+ debug("useHistoryState: replacing updated location:", updatedLocation);
+ history.replace(updatedLocation);
+ }
+
+ return [state, wrappedSetState];
+}
diff --git a/src/utils/hooks/useMountEffect.ts b/src/utils/hooks/useMountEffect.ts
new file mode 100644
index 0000000..9e001cc
--- /dev/null
+++ b/src/utils/hooks/useMountEffect.ts
@@ -0,0 +1,7 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useEffect, EffectCallback } from "react";
+
+// eslint-disable-next-line react-hooks/exhaustive-deps
+export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []);
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 0912e20..8ab095d 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,20 +1,10 @@
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under AGPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { EffectCallback, useEffect, useState } from "react";
-
-import { useHistory, useLocation } from "react-router-dom";
-
-import Debug from "debug";
-const debug = Debug("kristweb:utils");
-
-export const toHex = (input: ArrayBufferLike | Uint8Array): string =>
- [...(input instanceof Uint8Array ? input : new Uint8Array(input))]
- .map(b => b.toString(16).padStart(2, "0"))
- .join("");
-
-export const fromHex = (input: string): Uint8Array =>
- new Uint8Array((input.match(/.{1,2}/g) || []).map(b => parseInt(b, 16)));
+export * from "./misc/credits";
+export * from "./misc/math";
+export * from "./misc/promiseThrottle";
+export * from "./misc/sort";
export const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
@@ -26,133 +16,9 @@
)
);
-/**
- * Generates a secure random password based on a length and character set.
- *
- * Implementation mostly sourced from: {@link https://stackoverflow.com/a/51540480/1499974}
- *
- * See also: {@link https://github.com/chancejs/chancejs/issues/232#issuecomment-182500222}
- *
- * @param length - The desired length of the password.
- * @param charset - A string containing all the characters the password may
- * contain.
-*/
-export function generatePassword(
- length = 32,
- charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-"
-): string {
- // NOTE: talk about correctness with modulo and its bias (the charset is 64
- // characters right now anyway)
- return Array.from(crypto.getRandomValues(new Uint32Array(length)))
- .map(x => charset[x % charset.length])
- .join("");
-}
-
-/** Sort an array in-place in a human-friendly manner. */
-export function localeSort(arr: any[]): void {
- arr.sort((a, b) => a.localeCompare(b, undefined, {
- sensitivity: "base",
- numeric: true
- }));
-}
-
-/**
- * Sorting function that pushes nullish to the end of the array.
- *
- * @param key - The property of T to sort by.
- * @param human - Whether or not to use a human-friendly locale sort for
- * string values.
-*/
-export const keyedNullSort = (key: keyof T, human?: boolean) => (a: T, b: T, sortOrder?: "ascend" | "descend" | null): number => {
- // We effectively reverse the sort twice when sorting in 'descend' mode, as
- // ant-design will internally reverse the array, but we always want to push
- // nullish values to the end.
- const va = sortOrder === "descend" ? b[key] : a[key];
- const vb = sortOrder === "descend" ? a[key] : b[key];
-
- // Push nullish values to the end
- if (va === vb) return 0;
- if (va === undefined || va === null) return 1;
- if (vb === undefined || vb === null) return -1;
-
- if (typeof va === "string" && typeof vb === "string") {
- // Use localeCompare for strings
- const ret = va.localeCompare(vb, undefined, human ? {
- sensitivity: "base",
- numeric: true
- } : undefined);
- return sortOrder === "descend" ? -ret : ret;
- } else {
- // Use the built-in comparison for everything else (mainly numbers)
- return sortOrder === "descend"
- ? (vb as any) - (va as any)
- : (va as any) - (vb as any);
- }
-};
-
-// eslint-disable-next-line react-hooks/exhaustive-deps
-export const useMountEffect = (fn: EffectCallback): void => useEffect(fn, []);
-
-
-/**
- * Returns the ⌘ (command) symbol on macOS, and "Ctrl" everywhere else.
- *
- * NOTE: This is only evaluated on initial page load.
- *
- * REVIEW: This is a rather crude way to detect the platform, but it's the only
- * method I could find online (with an admittedly non-exhaustive search)
- */
+/** Returns the ⌘ (command) symbol on macOS, and "Ctrl" everywhere else. */
export const ctrl = /mac/i.test(navigator.platform) ? "\u2318" : "Ctrl";
-/**
- * Wrapper for useState that saves its value in the browser history stack
- * as location state. Note that this doesn't yet support computed state.
- *
- * The state's value must be serialisable, and less than 2 MiB.
- *
- * @param initialState - The initial value of the state.
- * @param stateKey - The key by which to store the state's value in the history
- * stack.
- */
-export function useHistoryState(
- initialState: S,
- stateKey: string
-): [S, (s: S) => void] {
- const history = useHistory();
- const location = useLocation>>();
-
- const [state, setState] = useState(
- location?.state?.[stateKey] ?? initialState
- );
-
- // Wraps setState to update the stored state value and replace the entry on
- // the history stack (via `updateLocation`).
- function wrappedSetState(newState: S): void {
- debug("useHistoryState: setting state %s to %o", stateKey, newState);
- updateLocation(newState);
- setState(newState);
- }
-
- // Merge the new state into the location state (using stateKey) and replace
- // the entry on the history stack.
- function updateLocation(newState: S) {
- const updatedLocation = {
- ...location,
- state: {
- ...location?.state,
- [stateKey]: newState
- }
- };
-
- debug("useHistoryState: replacing updated location:", updatedLocation);
- history.replace(updatedLocation);
- }
-
- return [state, wrappedSetState];
-}
-
-export const mod = (n: number, m: number): number => ((n % m) + m) % m;
-
export function toLookup(arr: string[]): Record {
const out: Record = {};
if (!arr) return out;
diff --git a/src/utils/krist/addressAlgo.ts b/src/utils/krist/addressAlgo.ts
new file mode 100644
index 0000000..5183375
--- /dev/null
+++ b/src/utils/krist/addressAlgo.ts
@@ -0,0 +1,34 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { sha256, doubleSHA256 } from "@utils/crypto";
+
+const hexToBase36 = (input: number): string => {
+ const byte = 48 + Math.floor(input / 7);
+ return String.fromCharCode(byte + 39 > 122 ? 101 : byte > 57 ? byte + 39 : byte);
+};
+
+export const makeV2Address = async (addressPrefix: string, key: string): Promise => {
+ const chars = ["", "", "", "", "", "", "", "", ""];
+ let chain = addressPrefix;
+ let hash = await doubleSHA256(key);
+
+ for (let i = 0; i <= 8; i++) {
+ chars[i] = hash.substring(0, 2);
+ hash = await doubleSHA256(hash);
+ }
+
+ for (let i = 0; i <= 8;) {
+ const index = parseInt(hash.substring(2 * i, 2 + (2 * i)), 16) % 9;
+
+ if (chars[index] === "") {
+ hash = await sha256(hash);
+ } else {
+ chain += hexToBase36(parseInt(chars[index], 16));
+ chars[index] = "";
+ i++;
+ }
+ }
+
+ return chain;
+};
diff --git a/src/utils/krist/commonmeta.ts b/src/utils/krist/commonmeta.ts
new file mode 100644
index 0000000..5863a5b
--- /dev/null
+++ b/src/utils/krist/commonmeta.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { getNameParts } from "./currency";
+
+export interface CommonMeta {
+ metaname?: string;
+ name?: string;
+ recipient?: string;
+
+ return?: string;
+ returnMetaname?: string;
+ returnName?: string;
+ returnRecipient?: string;
+
+ custom: Record;
+}
+
+export function parseCommonMeta(
+ nameSuffix: string,
+ metadata: string | undefined | null
+): CommonMeta | null {
+ if (!metadata) return null;
+
+ const custom: Record = {};
+ const out: CommonMeta = { custom };
+
+ const metaParts = metadata.split(";");
+ if (metaParts.length <= 0) return null;
+
+ const nameParts = getNameParts(nameSuffix, metaParts[0]);
+ if (nameParts) {
+ out.metaname = nameParts.metaname;
+ out.name = nameParts.nameWithSuffix;
+
+ out.recipient = nameParts.metaname
+ ? nameParts.metaname + "@" + nameParts.nameWithSuffix
+ : nameParts.nameWithSuffix;
+ }
+
+ for (let i = 0; i < metaParts.length; i++) {
+ const metaPart = metaParts[i];
+ const kv = metaPart.split("=", 2);
+
+ if (i === 0 && nameParts) continue;
+
+ if (kv.length === 1) {
+ custom[i.toString()] = kv[0];
+ } else {
+ custom[kv[0]] = kv.slice(1).join("=");
+ }
+ }
+
+ const rawReturn = out.return = custom.return;
+ if (rawReturn) {
+ const returnParts = getNameParts(nameSuffix, rawReturn);
+ if (returnParts) {
+ out.returnMetaname = returnParts.metaname;
+ out.returnName = returnParts.nameWithSuffix;
+
+ out.returnRecipient = returnParts.metaname
+ ? returnParts.metaname + "@" + returnParts.nameWithSuffix
+ : returnParts.nameWithSuffix;
+ }
+ }
+
+ return out;
+}
diff --git a/src/utils/krist/currency.ts b/src/utils/krist/currency.ts
new file mode 100644
index 0000000..feaff7c
--- /dev/null
+++ b/src/utils/krist/currency.ts
@@ -0,0 +1,161 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useSelector } from "react-redux";
+import { RootState } from "@store";
+
+import { KristCurrency } from "@api/types";
+
+import { memoize, escapeRegExp, truncate, toString } from "lodash-es";
+
+// -----------------------------------------------------------------------------
+// NAMES
+// -----------------------------------------------------------------------------
+export const BARE_NAME_REGEX = /^([a-z0-9]{1,64})$/;
+export const MAX_NAME_LENGTH = 64;
+export const isValidName =
+ (name: string): boolean => BARE_NAME_REGEX.test(name);
+
+// Cheap way to avoid RegExp DoS
+const MAX_NAME_SUFFIX_LENGTH = 6;
+const _cleanNameSuffix = (nameSuffix: string | undefined | null): string => {
+ // Ensure the name suffix is safe to put into a RegExp
+ const stringSuffix = toString(nameSuffix);
+ const shortSuffix = truncate(stringSuffix, { length: MAX_NAME_SUFFIX_LENGTH, omission: "" });
+ const escaped = escapeRegExp(shortSuffix);
+ return escaped;
+};
+export const cleanNameSuffix = memoize(_cleanNameSuffix);
+
+const _getNameRegex = (nameSuffix: string | undefined | null, metadata?: boolean): RegExp =>
+ new RegExp(`^(?:([a-z0-9-_]{1,32})@)?([a-z0-9]{1,64})(\\.${cleanNameSuffix(nameSuffix)})${metadata ? ";?" : "$"}`);
+export const getNameRegex = memoize(_getNameRegex);
+
+export interface NameParts {
+ metaname?: string;
+ name?: string;
+ nameSuffix?: string;
+ nameWithSuffix?: string;
+ recipient?: string;
+}
+export function getNameParts(
+ nameSuffix: string | undefined | null,
+ name: string | undefined
+): NameParts | undefined {
+ if (!nameSuffix || !name) return;
+
+ const nameMatches = getNameRegex(nameSuffix).exec(name);
+ if (!nameMatches) return undefined;
+
+ const mMetaname = nameMatches[1] || undefined;
+ const mName = nameMatches[2] || undefined;
+ const nameWithSuffix = mName ? mName + "." + nameSuffix : undefined;
+ const recipient = mMetaname
+ ? mMetaname + "@" + nameWithSuffix
+ : nameWithSuffix;
+
+ return {
+ metaname: mMetaname,
+ name: mName,
+ nameSuffix,
+ nameWithSuffix,
+ recipient
+ };
+}
+
+const _stripNameSuffixRegExp = (nameSuffix: string | undefined | null): RegExp =>
+ new RegExp(`\\.${cleanNameSuffix(nameSuffix)}$`);
+export const stripNameSuffixRegExp = memoize(_stripNameSuffixRegExp);
+
+export const stripNameSuffix = (nameSuffix: string | undefined | null, inp: string): string =>
+ inp.replace(stripNameSuffixRegExp(nameSuffix), "");
+
+export const stripNameFromMetadata = (nameSuffix: string | undefined | null, metadata: string): string =>
+ metadata.replace(getNameRegex(nameSuffix, true), "");
+
+// -----------------------------------------------------------------------------
+// ADDRESSES
+// -----------------------------------------------------------------------------
+const MAX_ADDRESS_PREFIX_LENGTH = 1;
+const _cleanAddressPrefix = (addressPrefix: string | undefined | null): string => {
+ // This might be slightly cursed when the max prefix length is 1 character,
+ // but let's call it future-proofing.
+ const stringPrefix = toString(addressPrefix);
+ const shortPrefix = truncate(stringPrefix, { length: MAX_ADDRESS_PREFIX_LENGTH, omission: "" });
+ const escaped = escapeRegExp(shortPrefix);
+ return escaped;
+};
+export const cleanAddressPrefix = memoize(_cleanAddressPrefix);
+
+// Supports v1 addresses too
+const _getAddressRegex = (addressPrefix: string | undefined | null): RegExp =>
+ new RegExp(`^(?:${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}|[a-f0-9]{10})$`);
+export const getAddressRegex = memoize(_getAddressRegex);
+
+// Only supports v2 addresses
+const _getAddressRegexV2 = (addressPrefix: string | undefined | null): RegExp =>
+ new RegExp(`^${cleanAddressPrefix(addressPrefix)}[a-z0-9]{9}$`);
+export const getAddressRegexV2 = memoize(_getAddressRegexV2);
+
+/**
+ * Returns whether or not an address is a valid Krist address for the current
+ * sync node.
+ *
+ * @param addressPrefix - The single-character address prefix provided by the
+ * sync node.
+ * @param address - The address to check for validity.
+ * @param allowV1 - Whether or not the function should validate v1 addresses.
+ * Note that as of February 2021, the Krist server no longer accepts
+ * any kind of transaction to/from a v1 address, so features that are
+ * validating an address for purpose of a transaction (e.g. the address
+ * picker) should NOT set this to true.
+ */
+export function isValidAddress(
+ addressPrefix: string | undefined | null,
+ address: string,
+ allowV1?: boolean
+): boolean {
+ return allowV1
+ ? getAddressRegex(addressPrefix).test(address)
+ : getAddressRegexV2(addressPrefix).test(address);
+}
+
+export const v1AddressRegex = /^[a-f0-9]{10}$/;
+export const isV1Address = (address: string): boolean =>
+ v1AddressRegex.test(address);
+
+// -----------------------------------------------------------------------------
+// MISC
+// -----------------------------------------------------------------------------
+
+/**
+ * Estimates the network mining hash-rate, returning it as a formatted string.
+ *
+ * TODO: Some people claimed they had a more accurate function for this. PRs
+ * welcome!
+ *
+ * @param work - The current block difficulty.
+ * @param secondsPerBlock - The number of seconds per block, as per the sync
+ * node's configuration.
+*/
+export function estimateHashRate(work: number, secondsPerBlock: number): string {
+ // Identical to the function from KristWeb 1
+ const rate = 1 / (work / (Math.pow(256, 6)) * secondsPerBlock);
+ if (rate === 0) return "0 H/s";
+
+ const sizes = ["H", "KH", "MH", "GH", "TH"];
+ const i = Math.min(Math.floor(Math.log(rate) / Math.log(1000)), sizes.length);
+ return parseFloat((rate / Math.pow(1000, i)).toFixed(2)) + " " + sizes[i] + "/s";
+}
+
+/** Hook to get the address prefix. */
+export const useAddressPrefix = (): string =>
+ useSelector((s: RootState) => s.node.currency.address_prefix);
+
+/** Hook to get the name suffix. */
+export const useNameSuffix = (): string =>
+ useSelector((s: RootState) => s.node.currency.name_suffix);
+
+/** Hook to get all the currency values. */
+export const useCurrency = (): KristCurrency =>
+ useSelector((s: RootState) => s.node.currency);
diff --git a/src/utils/krist/index.ts b/src/utils/krist/index.ts
new file mode 100644
index 0000000..0e36e11
--- /dev/null
+++ b/src/utils/krist/index.ts
@@ -0,0 +1,6 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+export * from "./addressAlgo";
+export * from "./commonmeta";
+export * from "./currency";
diff --git a/src/utils/misc/credits.ts b/src/utils/misc/credits.ts
new file mode 100644
index 0000000..df0f828
--- /dev/null
+++ b/src/utils/misc/credits.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState } from "react";
+import { useMountEffect } from "@utils/hooks";
+import packageJson from "../../../package.json";
+
+export function getAuthorInfo(): { authorName: string; authorURL: string; gitURL: string } {
+ const authorName = packageJson.author || "Lemmmy";
+ const authorURL = `https://github.com/${authorName}`;
+ const gitURL = packageJson.repository.url.replace(/\.git$/, "");
+
+ return { authorName, authorURL, gitURL };
+}
+
+export interface HostInfo {
+ host: {
+ name: string;
+ url: string;
+ };
+}
+
+export function useHostInfo(): HostInfo | undefined {
+ const [host, setHost] = useState();
+
+ useMountEffect(() => {
+ (async () => {
+ try {
+ // Add the host information if host.json exists
+ const hostFile = "host-attribution"; // Trick webpack into dynamic importing
+ const hostData = await import("../../__data__/" + hostFile + ".json");
+ setHost(hostData);
+ } catch (ignored) {
+ // Ignored
+ }
+ })();
+ });
+
+ return host;
+}
diff --git a/src/utils/misc/math.ts b/src/utils/misc/math.ts
new file mode 100644
index 0000000..2aab764
--- /dev/null
+++ b/src/utils/misc/math.ts
@@ -0,0 +1,12 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+export const toHex = (input: ArrayBufferLike | Uint8Array): string =>
+ [...(input instanceof Uint8Array ? input : new Uint8Array(input))]
+ .map(b => b.toString(16).padStart(2, "0"))
+ .join("");
+
+export const fromHex = (input: string): Uint8Array =>
+ new Uint8Array((input.match(/.{1,2}/g) || []).map(b => parseInt(b, 16)));
+
+export const mod = (n: number, m: number): number => ((n % m) + m) % m;
diff --git a/src/utils/misc/promiseThrottle.ts b/src/utils/misc/promiseThrottle.ts
new file mode 100644
index 0000000..82f14b2
--- /dev/null
+++ b/src/utils/misc/promiseThrottle.ts
@@ -0,0 +1,60 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { Dispatch, SetStateAction } from "react";
+import { throttle as lodashThrottle } from "lodash-es";
+
+/**
+ * Based on lodash _.throttle, returns a throttled Promise.
+ *
+ * The original function, `F`, must return a Promise. The throttled function's
+ * first parameter is a callback function, `cb`, which will be invoked with the
+ * promise returned by `F` when it is actually called. The remaining arguments
+ * will be passed directly to `F`.
+ *
+ * @param fn - The function to be throttled.
+ * @param timeout - The timeout of the throttle, in milliseconds.
+ * @param trailing - Whether or not to throttle on the trailing edge of the
+ * timeout.
+ */
+export function throttle P, P extends Promise>(fn: F, timeout: number, trailing?: boolean): (cb: (res: P) => void, ...args: Parameters) => void {
+ return lodashThrottle((cb: (res: P) => void, ...args: Parameters) => {
+ cb(fn(...args));
+ }, timeout, { leading: !trailing, trailing });
+}
+
+/**
+ * Based on lodash _.throttle, returns a function throttled on its trailing
+ * edge.
+ *
+ * The original function, `F`, must return a Promise. The throttled function's
+ * arguments will be passed to `F` if/when it is invoked. The parameters
+ * `setResult`, `setError`, and `setLoading` can be provided to dispatch React
+ * state changes based on the fulfillment of the Promise returned by `F`.
+ *
+ * @param fn - The function to be throttled.
+ * @param timeout - The timeout of the throttle, in milliseconds.
+ * @param trailing - Whether or not to throttle on the trailing edge of the
+ * timeout.
+ * @param setResult - React setState hook to call if the original function's
+ * Promise resolves.
+ * @param setError - React setState hook to call if the original function's
+ * Promise fails.
+ * @param setLoading - React setState hook to call when the original function's
+ * Promise settles.
+ */
+export function trailingThrottleState P, P extends Promise, R>(
+ fn: F,
+ timeout: number,
+ trailing?: boolean,
+ setResult?: Dispatch>,
+ setError?: Dispatch>,
+ setLoading?: Dispatch>
+): (...args: Parameters) => void {
+ return lodashThrottle((...args: Parameters) => {
+ fn(...args)
+ .then(r => { if (setResult) setResult(r); })
+ .catch(err => { if (setError) setError(err); })
+ .finally(() => { if (setLoading) setLoading(false); });
+ }, timeout, { leading: !trailing, trailing });
+}
diff --git a/src/utils/misc/sort.ts b/src/utils/misc/sort.ts
new file mode 100644
index 0000000..9494e97
--- /dev/null
+++ b/src/utils/misc/sort.ts
@@ -0,0 +1,44 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+/** Sort an array in-place in a human-friendly manner. */
+export function localeSort(arr: any[]): void {
+ arr.sort((a, b) => a.localeCompare(b, undefined, {
+ sensitivity: "base",
+ numeric: true
+ }));
+}
+
+/**
+ * Sorting function that pushes nullish to the end of the array.
+ *
+ * @param key - The property of T to sort by.
+ * @param human - Whether or not to use a human-friendly locale sort for
+ * string values.
+*/
+export const keyedNullSort = (key: keyof T, human?: boolean) => (a: T, b: T, sortOrder?: "ascend" | "descend" | null): number => {
+ // We effectively reverse the sort twice when sorting in 'descend' mode, as
+ // ant-design will internally reverse the array, but we always want to push
+ // nullish values to the end.
+ const va = sortOrder === "descend" ? b[key] : a[key];
+ const vb = sortOrder === "descend" ? a[key] : b[key];
+
+ // Push nullish values to the end
+ if (va === vb) return 0;
+ if (va === undefined || va === null) return 1;
+ if (vb === undefined || vb === null) return -1;
+
+ if (typeof va === "string" && typeof vb === "string") {
+ // Use localeCompare for strings
+ const ret = va.localeCompare(vb, undefined, human ? {
+ sensitivity: "base",
+ numeric: true
+ } : undefined);
+ return sortOrder === "descend" ? -ret : ret;
+ } else {
+ // Use the built-in comparison for everything else (mainly numbers)
+ return sortOrder === "descend"
+ ? (vb as any) - (va as any)
+ : (va as any) - (vb as any);
+ }
+};
diff --git a/src/utils/promiseThrottle.ts b/src/utils/promiseThrottle.ts
deleted file mode 100644
index 82f14b2..0000000
--- a/src/utils/promiseThrottle.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { Dispatch, SetStateAction } from "react";
-import { throttle as lodashThrottle } from "lodash-es";
-
-/**
- * Based on lodash _.throttle, returns a throttled Promise.
- *
- * The original function, `F`, must return a Promise. The throttled function's
- * first parameter is a callback function, `cb`, which will be invoked with the
- * promise returned by `F` when it is actually called. The remaining arguments
- * will be passed directly to `F`.
- *
- * @param fn - The function to be throttled.
- * @param timeout - The timeout of the throttle, in milliseconds.
- * @param trailing - Whether or not to throttle on the trailing edge of the
- * timeout.
- */
-export function throttle P, P extends Promise>(fn: F, timeout: number, trailing?: boolean): (cb: (res: P) => void, ...args: Parameters) => void {
- return lodashThrottle((cb: (res: P) => void, ...args: Parameters) => {
- cb(fn(...args));
- }, timeout, { leading: !trailing, trailing });
-}
-
-/**
- * Based on lodash _.throttle, returns a function throttled on its trailing
- * edge.
- *
- * The original function, `F`, must return a Promise. The throttled function's
- * arguments will be passed to `F` if/when it is invoked. The parameters
- * `setResult`, `setError`, and `setLoading` can be provided to dispatch React
- * state changes based on the fulfillment of the Promise returned by `F`.
- *
- * @param fn - The function to be throttled.
- * @param timeout - The timeout of the throttle, in milliseconds.
- * @param trailing - Whether or not to throttle on the trailing edge of the
- * timeout.
- * @param setResult - React setState hook to call if the original function's
- * Promise resolves.
- * @param setError - React setState hook to call if the original function's
- * Promise fails.
- * @param setLoading - React setState hook to call when the original function's
- * Promise settles.
- */
-export function trailingThrottleState P, P extends Promise, R>(
- fn: F,
- timeout: number,
- trailing?: boolean,
- setResult?: Dispatch>,
- setError?: Dispatch>,
- setLoading?: Dispatch>
-): (...args: Parameters) => void {
- return lodashThrottle((...args: Parameters) => {
- fn(...args)
- .then(r => { if (setResult) setResult(r); })
- .catch(err => { if (setError) setError(err); })
- .finally(() => { if (setLoading) setLoading(false); });
- }, timeout, { leading: !trailing, trailing });
-}
diff --git a/src/utils/table.tsx b/src/utils/table.tsx
deleted file mode 100644
index b326239..0000000
--- a/src/utils/table.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-// Copyright (c) 2020-2021 Drew Lemmy
-// This file is part of KristWeb 2 under AGPL-3.0.
-// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
-import { useState, useEffect, useCallback, useMemo, Dispatch, SetStateAction } from "react";
-import { TablePaginationConfig, TableProps, Pagination } from "antd";
-import { SorterResult } from "antd/lib/table/interface";
-import usePagination from "antd/lib/table/hooks/usePagination";
-
-import { useTranslation, TFunction } from "react-i18next";
-import { useIntegerSetting, useBooleanSetting } from "./settings";
-
-import { GlobalHotKeys } from "react-hotkeys";
-
-import { useHistory, useLocation } from "react-router-dom";
-
-import Debug from "debug";
-const debug = Debug("kristweb:table");
-
-export interface LookupFilterOptionsBase {
- limit?: number;
- offset?: number;
- orderBy?: FieldsT;
- order?: "ASC" | "DESC";
-}
-
-export interface LookupResponseBase {
- count: number;
- total: number;
-}
-
-export const handleLookupTableChange = (
- defaultPageSize: number,
- setOptions: (opts: LookupFilterOptionsBase) => void,
- setPaginationPos?: Dispatch>
-) =>
- (pagination: TablePaginationConfig, _: unknown, sorter: SorterResult | SorterResult[]): void => {
- if (!pagination?.pageSize)
- debug("pagination doesn't have pageSize!", pagination?.pageSize, pagination);
-
- const pageSize = (pagination?.pageSize) || defaultPageSize;
-
- // Update any linked pagination elements
- if (setPaginationPos && pagination) {
- setPaginationPos({
- current: pagination.current,
- pageSize: pagination.pageSize
- });
- }
-
- // This will trigger a data re-fetch
- setOptions({
- limit: pageSize,
- offset: pageSize * ((pagination?.current || 1) - 1),
-
- orderBy: sorter instanceof Array ? undefined : sorter.field as FieldsT,
- order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
- });
- };
-
-/** De-duplicates, sorts, and returns a list of page size options for table
- * pagination, including the user's custom one (if set). */
-export function getPageSizes(defaultPageSize: number): string[] {
- // De-duplicate the sizes if a default one is already in here
- const sizes = [...new Set([10, 15, 20, 50, 100, defaultPageSize])];
- sizes.sort((a, b) => a - b);
- return sizes.map(s => s.toString());
-}
-
-export const getTablePaginationConfig = (
- t: TFunction,
- res: ResponseT | undefined,
- totalKey: string,
- defaultPageSize: number,
- existingConfig?: TablePaginationConfig
-): TablePaginationConfig => ({
- ...existingConfig,
-
- size: "default",
- position: ["bottomRight"],
-
- showSizeChanger: true,
- defaultPageSize,
- pageSizeOptions: getPageSizes(defaultPageSize),
-
- total: res?.total || 0,
- showTotal: total => t(totalKey, { count: total || 0 }),
- });
-
-export function useMalleablePagination<
- ResultT,
- ResponseT extends LookupResponseBase,
- FieldsT extends string
->(
- res: ResponseT | undefined,
- results: ResultT[] | undefined, // Only really used for type inference
- totalKey: string,
- options: LookupFilterOptionsBase,
- setOptions: (opts: LookupFilterOptionsBase) => void,
- setPagination?: Dispatch>
-): {
- paginationTableProps: Pick, "onChange" | "pagination">;
- hotkeys: JSX.Element | null;
-} {
- const { t } = useTranslation();
-
- // The currentPageSize and currentPage may be provided by the useTableHistory
- // hook, which gets the values from the browser state
- const defaultPageSize = useIntegerSetting("defaultPageSize");
- const currentPageSize = options.limit ?? defaultPageSize;
- const currentPage = options.offset
- ? Math.max(Math.floor(options.offset / currentPageSize) + 1, 1)
- : 1;
-
- // All this is done to allow putting the pagination in the page header
- const [paginationPos, setPaginationPos] = useState({
- current: currentPage,
- pageSize: currentPageSize
- });
- const paginationConfig = getTablePaginationConfig(t, res, totalKey, currentPageSize, paginationPos);
- debug(defaultPageSize, currentPageSize, currentPage, paginationPos, paginationConfig);
- const [mergedPagination] = usePagination(
- results?.length || 0,
- paginationConfig,
- (current, pageSize) => {
- // Can't use onChange directly here unfortunately
- debug("linked pagination called onChange with %d, %d", current, pageSize);
-
- setPaginationPos({ current, pageSize });
- setOptions({
- ...options,
- limit: pageSize,
- offset: pageSize * ((current || 1) - 1)
- });
- }
- );
-
- const { hotkeys } = usePaginationHotkeys(
- currentPageSize, res?.total || 0,
- options, setOptions, setPaginationPos
- );
-
- // Update the pagination
- useEffect(() => {
- if (setPagination) {
- const ret: TablePaginationConfig = { ...mergedPagination };
- if (paginationPos?.current) ret.current = paginationPos.current;
- if (paginationPos?.pageSize) ret.pageSize = paginationPos.pageSize;
- setPagination(ret);
- }
- }, [res, options, mergedPagination, paginationPos, setPagination]);
-
- return {
- paginationTableProps: {
- onChange: handleLookupTableChange(defaultPageSize, setOptions, setPaginationPos),
- pagination: paginationConfig
- },
- hotkeys
- };
-}
-
-export function useLinkedPagination(): [
- JSX.Element,
- Dispatch>
- ] {
- // Used to display the pagination in the page header
- const [pagination, setPagination] = useState({});
-
- const paginationComponent = useMemo(() => (
-
- ), [pagination]);
-
- return [paginationComponent, setPagination];
-}
-
-export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined {
- switch (order) {
- case "ascend":
- return "ASC";
- case "descend":
- return "DESC";
- }
-}
-
-export function getFilterOptionsQuery(opts: LookupFilterOptionsBase): URLSearchParams {
- const qs = new URLSearchParams();
- if (opts.limit) qs.append("limit", opts.limit.toString());
- if (opts.offset) qs.append("offset", opts.offset.toString());
- if (opts.orderBy) qs.append("orderBy", opts.orderBy);
- if (opts.order) qs.append("order", opts.order);
- return qs;
-}
-
-/** Wraps the setOptions for a table, providing a sane default page size,
- * and storing state changes in the history stack. When the page is returned to,
- * the history stack is checked and location state is used as defaults. */
-export function useTableHistory<
- OptionsT extends LookupFilterOptionsBase
->(
- defaults: Partial & Pick
-): {
- options: OptionsT;
- setOptions: (opts: OptionsT) => void;
-} {
- // Used to get/set the browser history state
- const history = useHistory();
- const location = useLocation>();
- const { state } = location;
-
- const defaultPageSize = useIntegerSetting("defaultPageSize");
-
- // The table filter parameters
- const [options, setOptions] = useState({
- limit: state?.limit ?? defaults.limit ?? defaultPageSize,
- offset: state?.offset ?? defaults.offset ?? 0,
- orderBy: state?.orderBy ?? defaults.orderBy,
- order: state?.order ?? defaults.order
- } as OptionsT);
-
- function wrappedSetOptions(opts: OptionsT) {
- debug("table calling setOptions:", opts);
- updateLocation(opts);
- return setOptions(opts);
- }
-
- // Merge the options and extra state into the location state and replace the
- // entry on the history stack.
- function updateLocation(opts?: OptionsT) {
- const updatedLocation = {
- ...location,
- state: {
- ...location?.state,
- ...(opts ?? {})
- }
- };
-
- debug("replacing updated location:", updatedLocation);
- history.replace(updatedLocation);
- }
-
- return { options, setOptions: wrappedSetOptions };
-}
-
-/** Provides a GlobalHotKeys component that will add the left and right arrow
- * key hotkeys, allowing keyboard control of the table's pagination. */
-function usePaginationHotkeys(
- defaultPageSize: number,
- total: number | undefined,
- options: LookupFilterOptionsBase,
- setOptions: (opts: LookupFilterOptionsBase) => void,
- setPaginationPos?: Dispatch>
-): { hotkeys: JSX.Element | null } {
- const enableHotkeys = useBooleanSetting("tableHotkeys");
-
- const navigate = useCallback((direction: "prev" | "next") => {
- const mul = direction === "next" ? 1 : -1;
- const pageSize = options?.limit ?? defaultPageSize;
-
- // The offset for the lookup options
- const minOffset = 0;
- const maxOffset = (total || 1) - 1; // TODO: this isn't quite correct
- const newOffsetRaw = (options?.offset || 0) + (pageSize * mul);
- const newOffset = Math.min(Math.max(newOffsetRaw, minOffset), maxOffset);
-
- // The page number for paginationPos
- const newPage = Math.max(Math.floor(newOffset / pageSize) + 1, 1);
-
- debug(
- "hotkeys navigating %s (%d) across %d entries (%d total) ||| " +
- "old offset: %d new offset: %d (%d) new page: %d ||| " +
- "min is: %d max is: %d",
- direction, mul, pageSize, total,
- options?.offset, newOffset, newOffsetRaw, newPage,
- minOffset, maxOffset
- );
-
- // Update the table and pagination
- setOptions({ ...options, offset: newOffset });
- setPaginationPos?.({ current: newPage, pageSize });
- }, [defaultPageSize, total, options, setOptions, setPaginationPos]);
-
- // Enforce that the hotkeys get the newest `navigate` function, especially
- // when the `total` changes
- const hotkeys = useMemo(() => (
- { e?.preventDefault(); navigate("prev"); },
- NEXT: e => { e?.preventDefault(); navigate("next"); }
- }}
- />
- ), [navigate]);
-
- return {
- hotkeys: enableHotkeys ? hotkeys : null
- };
-}
-
-/** Returns an appropriate date column width for the given language and
- * settings. */
-export function useDateColumnWidth(): number {
- const showNativeDates = useBooleanSetting("showNativeDates");
- return showNativeDates ? 250 : 200;
-}
diff --git a/src/utils/table/mobileList.tsx b/src/utils/table/mobileList.tsx
new file mode 100644
index 0000000..3900ef8
--- /dev/null
+++ b/src/utils/table/mobileList.tsx
@@ -0,0 +1,56 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useMemo, ReactNode } from "react";
+import { List } from "antd";
+import { PaginationConfig } from "antd/lib/pagination";
+
+import { PaginationChangeFn } from "@utils/table/table";
+import { useBreakpoint } from "@utils/hooks";
+
+interface MobileListHookRes {
+ isMobile: boolean;
+ list: JSX.Element | null;
+}
+
+export type RenderItem = (item: T, index: number) => ReactNode;
+
+/** Returns a mobile-specific list view if the screen is small enough. */
+export function useMobileList(
+ loading: boolean,
+ res: T[],
+ rowKey: string,
+ paginationConfig: Omit | false | undefined,
+ paginationChange: PaginationChangeFn,
+ renderItem: (item: T, index: number) => ReactNode
+): MobileListHookRes {
+ const bps = useBreakpoint();
+ const isMobile = !bps.md;
+
+ console.log(paginationConfig);
+
+ const pagination: PaginationConfig = useMemo(() => ({
+ ...paginationConfig,
+ position: "bottom",
+ onChange: paginationChange
+ }), [paginationConfig, paginationChange]);
+
+ const list = useMemo(() => {
+ if (!isMobile) return null;
+
+ return
;
+ }, [isMobile, loading, res, rowKey, pagination, renderItem]);
+
+ return { isMobile, list };
+}
diff --git a/src/utils/table/table.tsx b/src/utils/table/table.tsx
new file mode 100644
index 0000000..84e6b5a
--- /dev/null
+++ b/src/utils/table/table.tsx
@@ -0,0 +1,329 @@
+// Copyright (c) 2020-2021 Drew Lemmy
+// This file is part of KristWeb 2 under AGPL-3.0.
+// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
+import { useState, useEffect, useCallback, useMemo, Dispatch, SetStateAction } from "react";
+import { TablePaginationConfig, TableProps, Pagination } from "antd";
+import { SorterResult } from "antd/lib/table/interface";
+import usePagination from "antd/lib/table/hooks/usePagination";
+
+import { useTranslation, TFunction } from "react-i18next";
+import { useIntegerSetting, useBooleanSetting } from "../settings";
+
+import { GlobalHotKeys } from "react-hotkeys";
+
+import { useHistory, useLocation } from "react-router-dom";
+
+import Debug from "debug";
+const debug = Debug("kristweb:table");
+
+export interface LookupFilterOptionsBase {
+ limit?: number;
+ offset?: number;
+ orderBy?: FieldsT;
+ order?: "ASC" | "DESC";
+}
+
+export interface LookupResponseBase {
+ count: number;
+ total: number;
+}
+
+export const handleLookupTableChange = (
+ defaultPageSize: number,
+ setOptions: (opts: LookupFilterOptionsBase) => void,
+ setPaginationPos?: Dispatch>
+) =>
+ (pagination: TablePaginationConfig, _: unknown, sorter: SorterResult | SorterResult[]): void => {
+ debug("pagination onChange", pagination, _, sorter);
+
+ if (!pagination?.pageSize)
+ debug("pagination doesn't have pageSize!", pagination?.pageSize, pagination);
+
+ const pageSize = (pagination?.pageSize) || defaultPageSize;
+
+ // Update any linked pagination elements
+ if (setPaginationPos && pagination) {
+ setPaginationPos({
+ current: pagination.current,
+ pageSize: pagination.pageSize
+ });
+ }
+
+ // This will trigger a data re-fetch
+ setOptions({
+ limit: pageSize,
+ offset: pageSize * ((pagination?.current || 1) - 1),
+
+ orderBy: sorter instanceof Array ? undefined : sorter.field as FieldsT,
+ order: sorter instanceof Array ? undefined : convertSorterOrder(sorter.order),
+ });
+ };
+
+/** De-duplicates, sorts, and returns a list of page size options for table
+ * pagination, including the user's custom one (if set). */
+export function getPageSizes(defaultPageSize: number): string[] {
+ // De-duplicate the sizes if a default one is already in here
+ const sizes = [...new Set([10, 15, 20, 50, 100, defaultPageSize])];
+ sizes.sort((a, b) => a - b);
+ return sizes.map(s => s.toString());
+}
+
+export const getTablePaginationConfig = (
+ t: TFunction,
+ res: ResponseT | undefined,
+ totalKey: string,
+ defaultPageSize: number,
+ existingConfig?: TablePaginationConfig
+): TablePaginationConfig => ({
+ ...existingConfig,
+
+ size: "default",
+ position: ["bottomRight"],
+
+ showSizeChanger: true,
+ defaultPageSize,
+ pageSizeOptions: getPageSizes(defaultPageSize),
+
+ total: res?.total || 0,
+ showTotal: total => t(totalKey, { count: total || 0 }),
+ });
+
+export type PaginationChangeFn = (
+ current: number | undefined,
+ pageSize: number | undefined
+) => void;
+
+export type PaginationTableProps = Pick, "onChange" | "pagination">;
+export function useMalleablePagination<
+ ResultT,
+ ResponseT extends LookupResponseBase,
+ FieldsT extends string
+>(
+ res: ResponseT | undefined,
+ results: ResultT[] | undefined, // Only really used for type inference
+ totalKey: string,
+ options: LookupFilterOptionsBase,
+ setOptions: (opts: LookupFilterOptionsBase) => void,
+ setPagination?: Dispatch>
+): {
+ paginationTableProps: PaginationTableProps;
+ hotkeys: JSX.Element | null;
+ paginationChange: PaginationChangeFn;
+} {
+ const { t } = useTranslation();
+
+ // The currentPageSize and currentPage may be provided by the useTableHistory
+ // hook, which gets the values from the browser state
+ const defaultPageSize = useIntegerSetting("defaultPageSize");
+ const currentPageSize = options.limit ?? defaultPageSize;
+ const currentPage = options.offset
+ ? Math.max(Math.floor(options.offset / currentPageSize) + 1, 1)
+ : 1;
+
+ // All this is done to allow putting the pagination in the page header
+ const [paginationPos, setPaginationPos] = useState({
+ current: currentPage,
+ pageSize: currentPageSize
+ });
+ const paginationConfig = getTablePaginationConfig(t, res, totalKey, currentPageSize, paginationPos);
+ debug(defaultPageSize, currentPageSize, currentPage, paginationPos, paginationConfig);
+
+ const onChange: PaginationChangeFn = useCallback((current, rawPageSize) => {
+ // Can't use onChange directly here unfortunately
+ debug("linked pagination called onChange with %d, %d", current, rawPageSize);
+
+ const pageSize = rawPageSize || defaultPageSize;
+
+ setPaginationPos({ current, pageSize });
+ setOptions({
+ ...options,
+ limit: pageSize,
+ offset: pageSize * ((current || 1) - 1)
+ });
+ }, [options, setOptions, defaultPageSize]);
+
+ const [mergedPagination] = usePagination(
+ results?.length || 0,
+ paginationConfig,
+ onChange
+ );
+
+ const { hotkeys } = usePaginationHotkeys(
+ currentPageSize, res?.total || 0,
+ options, setOptions, setPaginationPos
+ );
+
+ // Update the pagination
+ useEffect(() => {
+ if (setPagination) {
+ const ret: TablePaginationConfig = { ...mergedPagination };
+ if (paginationPos?.current) ret.current = paginationPos.current;
+ if (paginationPos?.pageSize) ret.pageSize = paginationPos.pageSize;
+ setPagination(ret);
+ }
+ }, [res, options, mergedPagination, paginationPos, setPagination]);
+
+ return {
+ paginationTableProps: {
+ onChange: handleLookupTableChange(defaultPageSize, setOptions, setPaginationPos),
+ pagination: paginationConfig
+ },
+ paginationChange: onChange,
+ hotkeys
+ };
+}
+
+export function useLinkedPagination(): [
+ JSX.Element,
+ Dispatch>
+ ] {
+ // Used to display the pagination in the page header
+ const [pagination, setPagination] = useState({});
+
+ const paginationComponent = useMemo(() => (
+
+ ), [pagination]);
+
+ return [paginationComponent, setPagination];
+}
+
+export function convertSorterOrder(order: "descend" | "ascend" | null | undefined): "ASC" | "DESC" | undefined {
+ switch (order) {
+ case "ascend":
+ return "ASC";
+ case "descend":
+ return "DESC";
+ }
+}
+
+export function getFilterOptionsQuery(opts: LookupFilterOptionsBase): URLSearchParams {
+ const qs = new URLSearchParams();
+ if (opts.limit) qs.append("limit", opts.limit.toString());
+ if (opts.offset) qs.append("offset", opts.offset.toString());
+ if (opts.orderBy) qs.append("orderBy", opts.orderBy);
+ if (opts.order) qs.append("order", opts.order);
+ return qs;
+}
+
+/** Wraps the setOptions for a table, providing a sane default page size,
+ * and storing state changes in the history stack. When the page is returned to,
+ * the history stack is checked and location state is used as defaults. */
+export function useTableHistory<
+ OptionsT extends LookupFilterOptionsBase
+>(
+ defaults: Partial & Pick
+): {
+ options: OptionsT;
+ setOptions: (opts: OptionsT) => void;
+} {
+ // Used to get/set the browser history state
+ const history = useHistory();
+ const location = useLocation>();
+ const { state } = location;
+
+ const defaultPageSize = useIntegerSetting("defaultPageSize");
+
+ // The table filter parameters
+ const [options, setOptions] = useState({
+ limit: state?.limit ?? defaults.limit ?? defaultPageSize,
+ offset: state?.offset ?? defaults.offset ?? 0,
+ orderBy: state?.orderBy ?? defaults.orderBy,
+ order: state?.order ?? defaults.order
+ } as OptionsT);
+
+ function wrappedSetOptions(opts: OptionsT) {
+ debug("table calling setOptions:", opts);
+ updateLocation(opts);
+ return setOptions(opts);
+ }
+
+ // Merge the options and extra state into the location state and replace the
+ // entry on the history stack.
+ function updateLocation(opts?: OptionsT) {
+ const updatedLocation = {
+ ...location,
+ state: {
+ ...location?.state,
+ ...(opts ?? {})
+ }
+ };
+
+ debug("replacing updated location:", updatedLocation);
+ history.replace(updatedLocation);
+ }
+
+ return { options, setOptions: wrappedSetOptions };
+}
+
+/** Provides a GlobalHotKeys component that will add the left and right arrow
+ * key hotkeys, allowing keyboard control of the table's pagination. */
+function usePaginationHotkeys(
+ defaultPageSize: number,
+ total: number | undefined,
+ options: LookupFilterOptionsBase,
+ setOptions: (opts: LookupFilterOptionsBase) => void,
+ setPaginationPos?: Dispatch>
+): { hotkeys: JSX.Element | null } {
+ const enableHotkeys = useBooleanSetting("tableHotkeys");
+
+ const navigate = useCallback((direction: "prev" | "next") => {
+ const mul = direction === "next" ? 1 : -1;
+ const pageSize = options?.limit ?? defaultPageSize;
+
+ // The offset for the lookup options
+ const minOffset = 0;
+ const maxOffset = (total || 1) - 1; // TODO: this isn't quite correct
+ const newOffsetRaw = (options?.offset || 0) + (pageSize * mul);
+ const newOffset = Math.min(Math.max(newOffsetRaw, minOffset), maxOffset);
+
+ // The page number for paginationPos
+ const newPage = Math.max(Math.floor(newOffset / pageSize) + 1, 1);
+
+ debug(
+ "hotkeys navigating %s (%d) across %d entries (%d total) ||| " +
+ "old offset: %d new offset: %d (%d) new page: %d ||| " +
+ "min is: %d max is: %d",
+ direction, mul, pageSize, total,
+ options?.offset, newOffset, newOffsetRaw, newPage,
+ minOffset, maxOffset
+ );
+
+ // Update the table and pagination
+ setOptions({ ...options, offset: newOffset });
+ setPaginationPos?.({ current: newPage, pageSize });
+ }, [defaultPageSize, total, options, setOptions, setPaginationPos]);
+
+ // Enforce that the hotkeys get the newest `navigate` function, especially
+ // when the `total` changes
+ const hotkeys = useMemo(() => (
+ { e?.preventDefault(); navigate("prev"); },
+ NEXT: e => { e?.preventDefault(); navigate("next"); }
+ }}
+ />
+ ), [navigate]);
+
+ return {
+ hotkeys: enableHotkeys ? hotkeys : null
+ };
+}
+
+/** Returns an appropriate date column width for the given language and
+ * settings. */
+export function useDateColumnWidth(): number {
+ const showNativeDates = useBooleanSetting("showNativeDates");
+ return showNativeDates ? 250 : 200;
+}
+
+export * from "./mobileList";
diff --git a/yarn.lock b/yarn.lock
index fbe80a9..cd2a616 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5366,6 +5366,11 @@
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+fast-equals@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.0.tgz#bef2c423af3939f2c54310df54c57e64cd2adefc"
+ integrity sha512-u6RBd8cSiLLxAiC04wVsLV6GBFDOXcTCgWkd3wEoFXgidPSoAJENqC9m7Jb2vewSvjBIfXV6icKeh3GTKfIaXA==
+
fast-glob@^3.1.1:
version "3.2.5"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661"