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"