Page MenuHomePhabricator

No OneTemporary

diff --git a/cashtab/src/components/Agora/Collection/styled.ts b/cashtab/src/components/Agora/Collection/styled.ts
index 7cdc8d33b..293ea5e5f 100644
--- a/cashtab/src/components/Agora/Collection/styled.ts
+++ b/cashtab/src/components/Agora/Collection/styled.ts
@@ -1,185 +1,185 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
import { token as tokenConfig } from 'config/token';
import { CollapseDownIcon } from 'components/Common/CustomIcons';
export const CollectionLoading = styled.div`
display: flex;
justify-content: center;
width: 100%;
margin: 12px auto;
`;
export const CollectionWrapper = styled.div<{ isCollapsed: boolean }>`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
color: ${props => props.theme.primaryText};
width: ${props => (props.isCollapsed ? '20%' : '100%')};
padding: 16px;
@media (max-width: 1600px) {
width: ${props => (props.isCollapsed ? '25%' : '100%')};
}
@media (max-width: 1400px) {
width: ${props => (props.isCollapsed ? '33.3%' : '100%')};
}
@media (max-width: 1000px) {
width: ${props => (props.isCollapsed ? '50%' : '100%')};
}
@media (max-width: 768px) {
width: 100%;
}
`;
export const CollectionSummary = styled.div<{ isCollapsed: boolean }>`
display: flex;
justify-content: center;
width: 100%;
flex-direction: row;
gap: 3px;
flex-wrap: wrap;
padding: 6px;
border-radius: ${props =>
props.isCollapsed ? '20px' : '20px 20px 0px 0px'};
background: ${props => props.theme.primaryBackground};
border: 1px solid ${props => props.theme.border};
border-bottom: none;
${props =>
props.isCollapsed && `border-bottom: 1px solid ${props.theme.border}`};
`;
export const ArrowWrapper = styled.div<{ isCollapsed: boolean }>`
width: 64px;
height: 64px;
display: flex;
svg {
height: 64px;
width: 64px;
transition: transform 0.3s ease;
fill: ${props => (props.isCollapsed ? 'red' : 'blue')};
transform: ${props =>
props.isCollapsed ? 'rotate(0deg)' : 'rotate(-180deg)'};
}
`;
export const Arrow = styled(CollapseDownIcon)``;
export const CollapsibleContent = styled.div<{ isCollapsed: boolean }>`
max-height: ${props => (props.isCollapsed ? '0' : '600px')};
width: 100%;
overflow: hidden;
transition: max-height 0.3s ease-out;
${props =>
!props.isCollapsed &&
`border-radius: 0 0 20px 20px;
background: ${props.theme.primaryBackground};
border-left: 1px solid ${props.theme.border};
border-right: 1px solid ${props.theme.border};
border-bottom: 1px solid ${props.theme.border};
`}
`;
export const TitleAndIconAndCollapseArrow = styled.button`
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
border: none;
background-color: transparent;
color: ${props => props.theme.primaryText};
cursor: pointer;
`;
export const CollectionTitle = styled.div`
display: flex;
flex-direction: column;
word-break: break-all;
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
font-weight: bold;
- line-height: 20px;
margin: 20px 0 0;
`;
export const CollectionIcon = styled.div<{ isCollapsed: boolean }>`
width: 100%;
height: 200px;
align-items: center;
justify-content: center;
overflow: hidden;
display: ${props => (props.isCollapsed ? 'flex' : 'none')};
img {
height: 100%;
width: auto;
border-radius: 200px;
}
`;
export const CollectionInfoRow = styled.div`
display: flex;
flex-direction: row;
width: 100%;
justify-content: center;
`;
export const ListedNft = styled.div`
display: flex;
flex-direction: row;
width: 100%;
flex-wrap: wrap;
margin-bottom: 36px;
`;
export const NftName = styled.div`
display: flex;
justify-content: center;
word-break: break-all;
width: 100%;
line-height: 25px;
padding: 6px 0;
`;
export const NftPrice = styled.div`
display: flex;
width: 100%;
justify-content: center;
line-height: 25px;
`;
export const NftInfoRow = styled.div`
display: flex;
width: 100%;
justify-content: center;
`;
export const ButtonRow = styled(NftInfoRow)`
margin: 6px 12px;
`;
export const NftIcon = styled.button<{ tokenId: string; size: number }>`
cursor: pointer;
border: none;
background-color: transparent;
display: flex;
margin: auto;
width: 256px;
height: 256px;
background: url(${props =>
`${tokenConfig.tokenIconsUrl}/${props.size}/${props.tokenId}.png`})
center no-repeat;
background-size: 100% 100%;
transition: all ease-in-out 1s;
:hover {
background-size: 150% 150%;
}
`;
export const NftSwiperSlide = styled.div`
background-color: ${props => props.theme.secondaryBackground};
`;
export const ModalFlex = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
color: ${props => props.theme.primaryText};
`;
export const ModalRow = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
`;
diff --git a/cashtab/src/components/Agora/OrderBook/styled.ts b/cashtab/src/components/Agora/OrderBook/styled.ts
index 3d681cef7..8bb9aa994 100644
--- a/cashtab/src/components/Agora/OrderBook/styled.ts
+++ b/cashtab/src/components/Agora/OrderBook/styled.ts
@@ -1,245 +1,248 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
import { token as tokenConfig } from 'config/token';
import { CashtabScroll } from 'components/Common/Atoms';
export const OrderBookLoading = styled.div`
display: flex;
justify-content: center;
width: 100%;
margin: 12px auto;
`;
export const OfferWrapper = styled.div<{ borderRadius: boolean }>`
border-radius: ${props => (props.borderRadius ? '20px' : '0 0 20px 20px')};
border: 1px solid ${props => props.theme.border};
width: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
background: ${props =>
!props.borderRadius
? props.theme.primaryBackground
: `linear-gradient(
0deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 100%
)`};
border-top: ${props => (!props.borderRadius ? 'none' : '')};
@media (max-width: 768px) {
margin-bottom: 20px;
width: 100%;
}
`;
export const OfferHeader = styled.div<{ noIcon?: boolean }>`
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: ${props => (props.noIcon ? '0 20px 0 20px' : '20px 20px 0')};
text-align: left;
border-radius: 20px 20px 0 0;
`;
export const OfferHeaderRow = styled.div<{ noIcon?: boolean }>`
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
margin-top: ${props => (props.noIcon ? '0' : '20px')};
margin-bottom: 20px;
color: ${props => props.theme.primaryText};
`;
export const OfferTitleCtn = styled.div`
display: flex;
flex-direction: column;
word-break: break-all;
align-items: center;
margin-top: 20px;
a {
margin: 0;
- font-size: 26px;
- line-height: 1.2em;
+ font-size: var(--text-2xl);
+ line-height: var(--text-2xl--line-height);
height: 1.2em;
overflow: hidden;
color: ${props => props.theme.primaryText};
font-weight: 600;
text-decoration: none;
text-align: center;
:hover {
color: ${props => props.theme.accent};
}
}
span {
color: ${props => props.theme.secondaryText};
}
`;
export const OfferIcon = styled.button<{ size: number; tokenId: string }>`
cursor: pointer;
border: none;
background-color: transparent;
width: 96px;
height: 96px;
flex-shrink: 0;
border-radius: 96px;
background: url(${props =>
`${tokenConfig.tokenIconsUrl}/${props.size}/${props.tokenId}.png`})
center no-repeat;
background-size: 100% 100%;
transition: all ease-in-out 200ms;
:hover {
transform: scale(1.2);
}
`;
export const OfferDetailsCtn = styled.div`
width: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
`;
export const DepthBarCol = styled.div<{ noIcon?: boolean }>`
width: 100%;
max-height: 110px;
overflow-y: auto;
${CashtabScroll}
display: flex;
flex-direction: column-reverse;
max-height: ${props => (props.noIcon ? '150px' : '110px')};
`;
export const OrderBookRow = styled.button<{ selected: boolean }>`
color: ${props =>
props.selected
? `${props.theme.primaryText}!important`
: 'rgba(255, 255, 255, 0.6)'};
font-weight: ${props => (props.selected ? '600' : '400')};
height: 32px !important;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
border: none;
width: 100%;
justify-content: center;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.1);
border-color: ${props =>
props.selected
? `rgba(0, 231, 129, 0.6)!important`
: 'rgba(0,0,0, 0.3)'};
flex-shrink: 0;
position: relative;
:hover {
color: #fff;
}
`;
export const OrderbookPrice = styled.div`
display: flex;
align-items: center;
gap: 3px;
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
z-index: 1;
`;
export const DepthBar = styled.div<{
depthPercent: number;
isMaker: boolean;
isUnacceptable: boolean;
}>`
display: flex;
flex-direction: row;
position: absolute;
top: 0;
right: 0;
background-color: ${props =>
props.isMaker
? props.isUnacceptable
? props.theme.agoraDepthBarUnacceptable
: props.theme.agoraDepthBarOwnOffer
: props.theme.agoraDepthBar};
height: 100%;
width: ${props => props.depthPercent}%;
`;
export const TentativeAcceptBar = styled.div<{ acceptPercent: number }>`
display: flex;
flex-direction: row;
position: absolute;
top: 0;
right: 0;
background-color: ${props => props.theme.genesisGreen};
height: 100%;
width: ${props => props.acceptPercent}%;
`;
export const SliderRow = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 20px 20px 0px;
& > span {
margin-right: 10px;
font-weight: 600;
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
color: ${props => props.theme.genesisGreen};
}
input {
accent-color: ${props => props.theme.genesisGreen};
}
`;
export const BuyOrderCtn = styled.div`
display: flex;
flex-direction: column;
word-break: break-all;
padding: 20px;
color: ${props => props.theme.primaryText};
border-radius: 0 0 20px 20px;
flex-grow: 1;
align-items: flex-end;
text-align: right;
& > div {
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
opacity: 0.7;
- line-height: 1em;
}
h3 {
- font-size: 18px;
+ font-size: var(--text-2xl);
margin: 0;
margin-bottom: 20px;
}
button {
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
padding: 14px 10px;
border-radius: 4px;
margin-top: 30px;
margin-bottom: 0;
margin-top: auto;
}
`;
export const MintIconSpotWrapper = styled.div`
svg {
height: 24px;
width: 24px;
}
`;
export const DeltaSpan = styled.span`
color: ${props => props.theme.secondaryAccent};
`;
export const AgoraWarningParagraph = styled.div`
font-weight: bold;
text-align: center;
color: ${props => props.theme.secondaryAccent};
`;
diff --git a/cashtab/src/components/Agora/styled.ts b/cashtab/src/components/Agora/styled.ts
index ddb4f796e..1e2879742 100644
--- a/cashtab/src/components/Agora/styled.ts
+++ b/cashtab/src/components/Agora/styled.ts
@@ -1,120 +1,121 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
export const ActiveOffers = styled.div`
color: ${props => props.theme.primaryText};
width: 100%;
h2 {
margin: 0 0 20px;
margin-top: 10px;
}
`;
export const OfferTitle = styled.div`
margin-top: 12px;
margin-bottom: 12px;
color: ${props => props.theme.primaryText};
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
text-align: center;
font-weight: bold;
`;
export const OfferTable = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
width: 100%;
margin-top: 20px;
@media (max-width: 1600px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 1000px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 768px) {
display: flex;
flex-direction: column;
}
`;
export const OfferCol = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 3px;
`;
export const AgoraHeader = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
color: ${props => props.theme.primaryText};
@media (max-width: 768px) {
flex-direction: column;
margin: 20px 0;
}
h2 {
margin: 0;
}
> div {
display: flex;
align-items: center;
gap: 20px;
@media (max-width: 768px) {
margin-top: 10px;
gap: 0px;
flex-direction: column;
}
}
`;
export const ManageSwitch = styled.span`
cursor: pointer;
user-select: none;
padding-left: 20px;
position: relative;
:hover {
color: ${props => props.theme.accent};
}
@media (max-width: 768px) {
padding-left: 0;
padding-top: 20px;
margin-top: 10px;
}
::after {
content: '';
height: 100%;
width: 1px;
background-color: #fff;
position: absolute;
left: 0;
@media (max-width: 768px) {
width: 100%;
height: 1px;
top: 0px;
}
}
`;
export const SortSwitch = styled.div<{ active: boolean; disabled: boolean }>`
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
user-select: none;
opacity: ${props => (props.disabled ? '0.5' : '1')};
padding: 5px 10px;
border-radius: 5px;
background-color: ${props =>
props.active ? props.theme.secondaryBackground : ''};
position: relative;
> div {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%);
}
`;
diff --git a/cashtab/src/components/App/styles.ts b/cashtab/src/components/App/styles.ts
index c1cc7426e..38e258185 100644
--- a/cashtab/src/components/App/styles.ts
+++ b/cashtab/src/components/App/styles.ts
@@ -1,410 +1,431 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled, { createGlobalStyle, css } from 'styled-components';
import { CashtabScroll } from 'components/Common/Atoms';
import { CashtabTheme } from 'assets/styles/theme';
export const ExtensionFrame = createGlobalStyle`
html, body {
min-width: 400px;
min-height: 600px;
}
`;
/**
* GlobalStyle component
* Modify Toastify variables and styles here
*/
export const GlobalStyle = createGlobalStyle`
:root {
--toastify-icon-color-success: ${props => props.theme.genesisGreen};
--toastify-color-success: ${props => props.theme.genesisGreen};
--toastify-toast-padding: 20px;
--toastify-color-progress-dark: ${props => props.theme.accent};
+
+ --text-sm: 0.875rem;
+ --text-base: 1rem; /* 16px */
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+ --text-2xl: 1.5rem;
+ --text-3xl: 1.875rem;
+
+ --text-sm--line-height: 1.428;
+ --text-base--line-height: 1.5;
+ --text-lg--line-height: 1.556;
+ --text-xl--line-height: 1.4;
+ --text-2xl--line-height: 1.333;
+ --text-3xl--line-height: 1.2;
+ --leading-7: 1.75;
}
.Toastify__toast {
margin-bottom: 0;
justify-content: center;
border: 1px solid ${props => props.theme.accent};
a {
text-decoration: none;
}
}
*::placeholder {
color: ${(props: { theme: CashtabTheme }) =>
props.theme.secondaryText} !important;
}
a {
color: ${props => props.theme.accent};
&:hover {
color: ${props => props.theme.secondaryAccent};
text-decoration: none;
}
}
+
+ p {
+ line-height: var(--leading-7);
+ }
`;
export const CustomApp = styled.div`
text-align: center;
background-color: ${props => props.theme.secondaryBackground};
background-size: 100px 171px;
background-image: ${props => props.theme.backgroundImage};
background-attachment: fixed;
min-height: 100vh;
box-sizing: border-box;
*,
*:before,
*:after {
box-sizing: inherit;
}
`;
export const WalletBody = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
flex-direction: row-reverse;
max-height: 100vh;
max-width: 2000px;
margin: auto;
@media (max-width: 768px) {
max-height: unset;
}
`;
export const WalletCtn = styled.div<{ showFooter?: boolean }>`
width: 100%;
background: ${props => props.theme.primaryBackground};
position: relative;
min-height: 100vh;
max-height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
${CashtabScroll}
@media (max-width: 768px) {
max-height: unset;
overflow-y: unset;
padding: 0 0 100px;
min-height: ${props =>
props.showFooter ? 'calc(100vh - 70px)' : '100vh'};
background: ${props => props.theme.primaryBackground};
}
`;
export const Footer = styled.div`
width: 230px;
background: ${props => props.theme.primaryBackground};
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
${CashtabScroll}
padding: 0 10px;
border-right: 1px solid ${props => props.theme.border};
@media (max-width: 768px) {
width: 100%;
z-index: 100;
height: 70px;
border-top: 1px solid ${props => props.theme.border};
align-items: center;
justify-content: space-between;
padding: 0;
position: fixed;
flex-direction: row;
bottom: 0;
overflow: visible;
box-shadow: 0px 0px 24px 1px ${props => props.theme.menuGlow};
border-right: none;
}
`;
export const NavWrapper = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
padding-bottom: 30px;
@media (max-width: 768px) {
width: 100%;
height: 100%;
cursor: pointer;
align-items: center;
justify-content: center;
padding-bottom: 0;
}
`;
export const NavIcon = styled.span<{ clicked?: boolean }>`
display: none;
@media (max-width: 768px) {
@media (hover: hover) {
${NavWrapper}:hover & {
background-color: ${props =>
props.clicked
? 'transparent'
: props.theme.secondaryAccent};
::before,
::after {
background-color: ${props => props.theme.secondaryAccent};
}
}
}
position: relative;
background-color: ${props =>
props.clicked ? 'transparent' : props.theme.secondaryText};
width: 40px;
height: 3px;
border-radius: 10px;
display: inline-block;
transition: all 200ms ease-in-out;
&::before,
&::after {
content: '';
background-color: ${props => props.theme.secondaryText};
width: 40px;
height: 3px;
border-radius: 10px;
display: inline-block;
position: absolute;
left: 0;
transition: all 200ms ease-in-out;
}
&::before {
top: ${props => (props.clicked ? '0' : '-0.8rem')};
transform: ${props =>
props.clicked ? 'rotate(135deg)' : 'rotate(0)'};
}
&::after {
top: ${props => (props.clicked ? '0' : '0.8rem')};
transform: ${props =>
props.clicked ? 'rotate(-135deg)' : 'rotate(0)'};
}
}
`;
export const NavMenu = styled.div<{ open?: boolean }>`
@media (max-width: 768px) {
position: absolute;
bottom: 70px;
right: ${props => (props.open ? '0' : '-300px')};
display: flex;
flex-direction: column;
border: 1px solid ${props => props.theme.border};
overflow: auto;
transition: all 250ms ease-in-out;
max-height: calc(100vh - 70px);
background-color: ${props => props.theme.primaryBackground};
border-bottom: none;
border-right: none;
${CashtabScroll}
}
`;
const NavButtonDesktop = css<{ active?: boolean }>`
width: 100%;
cursor: pointer;
padding: 5px 10px;
display: flex;
align-items: center;
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
border: none;
background: none;
margin-bottom: 5px;
user-select: none;
flex-direction: row-reverse;
justify-content: flex-start;
text-align: left;
color: ${props => props.theme.secondaryText};
border-radius: 5px;
font-weight: normal;
font-family: 'Poppins';
span,
p {
line-height: 1em;
}
:hover {
color: ${props => props.theme.secondaryAccent};
svg,
g,
path {
fill: ${props => props.theme.secondaryAccent};
}
background: ${props => props.theme.secondaryBackground};
}
svg {
fill: ${props => props.theme.secondaryText};
width: 24px;
height: 24px;
margin-right: 8px;
@media (max-width: 768px) {
margin-right: 0;
}
}
g,
path {
fill: ${props => props.theme.secondaryText};
}
${({ active, ...props }) =>
active &&
`
color: ${props.theme.primaryBackground} !important;
background: ${props.theme.accent} !important;
svg, g, path {
fill: ${props.theme.primaryBackground} !important;
}
font-weight: 700;
`}
@media (max-width: 768px) {
border-radius: 0px;
}
`;
export const NavButton = styled.button`
:focus,
:active {
outline: none;
}
${NavButtonDesktop}
justify-content: flex-end;
@media (max-width: 768px) {
height: 100%;
justify-content: center;
align-items: center;
padding: 0;
margin-bottom: 0;
span {
display: none;
}
}
`;
export const NavItem = styled.button`
${NavButtonDesktop}
@media (max-width: 768px) {
flex-direction: row;
justify-content: space-between;
- font-size: 22px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
padding: 10px 20px 10px;
margin-bottom: 0;
}
svg {
@media (max-width: 768px) {
margin-left: 8px;
margin-right: 0;
width: 28px;
height: 28px;
}
}
p {
flex: 2;
margin: 0;
}
`;
export const ScreenWrapper = styled.div`
padding: 20px;
background: ${props => props.theme.primaryBackground};
@media (max-width: 768px) {
padding: 0px;
max-height: unset;
}
`;
export const HeaderCtn = styled.div`
display: none;
@media (max-width: 768px) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 10px 0;
gap: 6px;
background: ${props => props.theme.primaryBackground};
border-bottom: 1px solid ${props => props.theme.border};
}
`;
export const CashtabLogo = styled.img`
width: 100px;
`;
// Easter egg styled component not used in extension/src/components/App.js
export const EasterEgg = styled.img`
position: fixed;
bottom: -195px;
margin: 0;
right: 10%;
transition-property: bottom;
transition-duration: 1.5s;
transition-timing-function: ease-out;
:hover {
bottom: 0;
}
@media screen and (max-width: 768px) {
display: none;
}
`;
export const DesktopLogo = styled.div`
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 20px;
margin-bottom: 5px;
img {
width: 90%;
}
@media (max-width: 768px) {
display: none;
}
`;
export const HeaderInfoCtn = styled.div`
display: flex;
width: 100%;
align-items: center;
flex-direction: row-reverse;
padding: 20px;
background: ${props => props.theme.primaryBackground};
border-bottom: 1px solid ${props => props.theme.border};
position: relative;
position: sticky;
top: 0;
z-index: 99;
@media (max-width: 768px) {
flex-direction: column;
position: relative;
padding: 10px 20px;
}
`;
export const BalanceHeaderContainer = styled.div`
box-sizing: border-box;
transition: all 0.5s ease-in-out;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
*,
*:before,
*:after {
box-sizing: inherit;
}
@media (max-width: 768px) {
width: 100%;
padding: 5px 20px;
align-items: center;
}
`;
diff --git a/cashtab/src/components/Common/Atoms.tsx b/cashtab/src/components/Common/Atoms.tsx
index 47fc46a65..87a8eeacf 100644
--- a/cashtab/src/components/Common/Atoms.tsx
+++ b/cashtab/src/components/Common/Atoms.tsx
@@ -1,204 +1,207 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled, { css } from 'styled-components';
import { CopyIconButton } from 'components/Common/Buttons';
import { explorer } from 'config/explorer';
export const CashtabScroll = css`
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 0 rgba(0, 0, 0, 0);
background-color: ${props => props.theme.secondaryBackground};
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: ${props => props.theme.accent};
}
`;
export const WarningFont = styled.div`
color: ${props => props.theme.wallet.text.primary};
`;
export const LoadingCtn = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 400px;
flex-direction: column;
svg {
width: 50px;
height: 50px;
fill: ${props => props.theme.accent};
}
`;
export const TxLink = styled.a`
color: ${props => props.theme.primary};
`;
export const TokenParamLabel = styled.span`
font-weight: bold;
`;
export const AlertMsg = styled.p`
color: ${props => props.theme.formError} !important;
`;
export const ConvertAmount = styled.div`
color: ${props => props.theme.primaryText};
width: 100%;
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
margin-bottom: 10px;
@media (max-width: 768px) {
- font-size: 12px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
}
`;
export const SwitchLabel = styled.div`
text-align: left;
color: ${props => props.theme.primaryText};
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
word-break: break-all;
`;
export const Alert = styled.div<{ noWordBreak?: boolean }>`
background-color: #fff2f0;
border-radius: 12px;
color: red;
padding: 12px;
margin: 12px 0;
${props =>
typeof props.noWordBreak === 'undefined' && `word-break: break-all`};
`;
export const Info = styled.div`
background-color: #fff2f0;
border-radius: 12px;
color: ${props => props.theme.accent};
padding: 12px;
margin: 12px 0;
`;
export const BlockNotification = styled.div`
display: flex;
flex-direction: column;
`;
export const BlockNotificationLink = styled.a`
display: flex;
justify-content: flex-start;
width: 100%;
text-decoration: none;
`;
export const BlockNotificationDesc = styled.div`
display: flex;
justify-content: flex-start;
width: 100%;
`;
export const TokenIdAndCopyIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
svg {
width: 18px;
height: 18px;
:hover {
g {
fill: ${props => props.theme.secondaryAccent};
}
fill: ${props => props.theme.secondaryAccent};
}
}
`;
interface TokenIdPreviewProps {
tokenId: string;
}
export const TokenIdPreview: React.FC<TokenIdPreviewProps> = ({ tokenId }) => {
return (
<TokenIdAndCopyIcon>
<a
href={`${explorer.blockExplorerUrl}/tx/${tokenId}`}
target="_blank"
rel="noopener noreferrer"
>
{tokenId.slice(0, 3)}
...
{tokenId.slice(-3)}
</a>
<CopyIconButton
name={`Copy Token ID`}
data={tokenId}
showToast
customMsg={`Token ID "${tokenId}" copied to clipboard`}
/>
</TokenIdAndCopyIcon>
);
};
export const PageHeader = styled.h2`
margin: 0;
margin-top: 20px;
color: ${props => props.theme.primaryText};
display: flex;
align-items: center;
justify-content: center;
svg {
height: 30px;
width: 30px;
margin-left: 10px;
}
svg path {
fill: #fff !important;
}
`;
const CopyTokenIdWrapper = styled.div`
display: flex;
align-items: center;
color: ${props => props.theme.secondaryText};
svg {
width: 16px;
height: 16px;
g {
fill: ${props => props.theme.secondaryText};
}
fill: ${props => props.theme.secondaryText};
:hover {
g {
fill: ${props => props.theme.secondaryAccent};
}
fill: ${props => props.theme.secondaryAccent};
}
}
button {
display: flex;
align-items: center;
}
`;
interface CopyTokenIdProps {
tokenId: string;
}
export const CopyTokenId: React.FC<CopyTokenIdProps> = ({ tokenId }) => {
return (
<CopyTokenIdWrapper>
{tokenId.slice(0, 3)}
...
{tokenId.slice(-3)}
<CopyIconButton
name={`Copy Token ID`}
data={tokenId}
showToast
customMsg={`Token ID "${tokenId}" copied to clipboard`}
/>
</CopyTokenIdWrapper>
);
};
diff --git a/cashtab/src/components/Common/BalanceHeader.tsx b/cashtab/src/components/Common/BalanceHeader.tsx
index 8760cad49..255c79b92 100644
--- a/cashtab/src/components/Common/BalanceHeader.tsx
+++ b/cashtab/src/components/Common/BalanceHeader.tsx
@@ -1,197 +1,200 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import * as React from 'react';
import styled from 'styled-components';
import CashtabSettings, {
supportedFiatCurrencies,
} from 'config/CashtabSettings';
import appConfig from 'config/app';
import { toXec } from 'wallet';
import { FIRMA } from 'constants/tokens';
export const BalanceXec = styled.div`
display: flex;
flex-direction: column;
- font-size: 28px;
+ font-size: var(--text-3xl);
+ line-height: var(--text-3xl--line-height);
margin-bottom: 0px;
font-weight: bold;
- line-height: 1.4em;
@media (max-width: 768px) {
- font-size: 24px;
+ font-size: var(--text-2xl);
}
`;
export const BalanceRow = styled.div<{
isXecx?: boolean;
hideBalance: boolean;
}>`
display: flex;
justify-content: flex-start;
width: 100%;
gap: 3px;
color: ${props =>
props.hideBalance
? 'transparent'
: props.isXecx
? props.theme.accent
: '#fff'};
text-shadow: ${props =>
props.hideBalance
? props.isXecx
? `0 0 15px ${props.theme.accent}`
: '0 0 15px #fff'
: 'none'};
${props =>
props.hideBalance &&
props.isXecx &&
`a {
color: transparent;
}`}
`;
export const BalanceFiat = styled.div<{ balanceVisible: boolean }>`
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
@media (max-width: 768px) {
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
}
color: ${props =>
props.balanceVisible ? 'transparent' : props.theme.secondaryText};
text-shadow: ${props => (props.balanceVisible ? '0 0 15px #fff' : 'none')};
`;
const EcashPrice = styled.p`
padding: 0;
margin: 0;
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
overflow: hidden;
text-overflow: ellipsis;
color: ${props => props.theme.secondaryText};
`;
interface BalanceHeaderProps {
balanceSats: number;
/** In decimalized XECX */
balanceXecx: number;
/** In decimalized firma */
balanceFirma: number;
settings: CashtabSettings;
fiatPrice: null | number;
firmaPrice: null | number;
userLocale?: string;
}
const BalanceHeader: React.FC<BalanceHeaderProps> = ({
balanceSats,
balanceXecx,
balanceFirma,
settings = new CashtabSettings(),
fiatPrice = null,
firmaPrice = null,
userLocale = 'en-US',
}) => {
// If navigator.language is undefined, default to en-US
userLocale = typeof userLocale === 'undefined' ? 'en-US' : userLocale;
const renderFiatValues =
typeof fiatPrice === 'number' && typeof firmaPrice === 'number';
// Display XEC balance formatted for user's browser locale
const balanceXec = toXec(balanceSats);
const formattedBalanceXec = balanceXec.toLocaleString(userLocale, {
minimumFractionDigits: appConfig.cashDecimals,
maximumFractionDigits: appConfig.cashDecimals,
});
const formattedBalanceXecx = balanceXecx.toLocaleString(userLocale, {
minimumFractionDigits: appConfig.cashDecimals,
maximumFractionDigits: appConfig.cashDecimals,
});
const formattedBalanceFirma = balanceFirma.toLocaleString(userLocale, {
minimumFractionDigits: FIRMA.token.genesisInfo.decimals,
maximumFractionDigits: FIRMA.token.genesisInfo.decimals,
});
const balanceXecEquivalents = balanceXec + balanceXecx;
// Note we want FIRMA to be included in fiat balance at 1 USD
// But not all users will have USD selected for their foreign currency
// So, we cannot "just add it" unless the user is working with USD;
// we adjust it by firmaPrice (which is 1 for users with USD selected)
// Display fiat balance formatted for user's browser locale
const formattedBalanceFiat = renderFiatValues
? (
balanceXecEquivalents * fiatPrice +
balanceFirma * firmaPrice
).toLocaleString(userLocale, {
minimumFractionDigits: appConfig.fiatDecimals,
maximumFractionDigits: appConfig.fiatDecimals,
})
: undefined;
// Display exchange rate formatted for user's browser locale
const formattedExchangeRate = renderFiatValues
? fiatPrice.toLocaleString(userLocale, {
minimumFractionDigits: appConfig.pricePrecisionDecimals,
maximumFractionDigits: appConfig.pricePrecisionDecimals,
})
: undefined;
return (
<>
<BalanceXec>
<BalanceRow
title="Balance XEC"
hideBalance={settings.balanceVisible === false}
>
{formattedBalanceXec} {appConfig.ticker}
</BalanceRow>
{balanceXecx !== 0 && (
<BalanceRow
isXecx
title="Balance XECX"
hideBalance={settings.balanceVisible === false}
>
{formattedBalanceXecx}{' '}
<a href={`#/token/${appConfig.vipTokens.xecx.tokenId}`}>
XECX
</a>
</BalanceRow>
)}
{balanceFirma !== 0 && (
<BalanceRow
isXecx
title="Balance FIRMA"
hideBalance={settings.balanceVisible === false}
>
{formattedBalanceFirma}{' '}
<a href={`#/token/${FIRMA.tokenId}`}>FIRMA</a>
</BalanceRow>
)}
</BalanceXec>
{renderFiatValues && (
<>
<BalanceFiat
title="Balance in Local Currency"
balanceVisible={settings.balanceVisible === false}
>
{supportedFiatCurrencies[settings.fiatCurrency].symbol}
{formattedBalanceFiat}&nbsp;
{supportedFiatCurrencies[
settings.fiatCurrency
].slug.toUpperCase()}
</BalanceFiat>
<EcashPrice title="Price in Local Currency">
1 {appConfig.ticker} = {formattedExchangeRate}{' '}
{settings.fiatCurrency.toUpperCase()}
</EcashPrice>
</>
)}
</>
);
};
export default BalanceHeader;
diff --git a/cashtab/src/components/Common/Buttons.tsx b/cashtab/src/components/Common/Buttons.tsx
index 3e4294164..a0f067a15 100644
--- a/cashtab/src/components/Common/Buttons.tsx
+++ b/cashtab/src/components/Common/Buttons.tsx
@@ -1,190 +1,192 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { ReactNode } from 'react';
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import { CopyPasteIcon } from 'components/Common/CustomIcons';
import { toast } from 'react-toastify';
const BaseButtonOrLinkCss = css<{ disabled?: boolean }>`
- font-size: 24px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
padding: 20px 12px;
border-radius: 9px;
transition: all 0.5s ease;
width: 100%;
margin-bottom: 20px;
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
:hover {
background-position: right center;
-webkit-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
-moz-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
}
@media (max-width: 768px) {
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
padding: 15px 0;
}
display: flex;
justify-content: center;
`;
const CashtabBaseButton = styled.button`
${BaseButtonOrLinkCss}
`;
const CashtabBaseLink = styled(Link)`
${BaseButtonOrLinkCss}
`;
const PrimaryButtonOrLinkCss = css<{ disabled?: boolean }>`
color: ${props =>
props.disabled
? props.theme.buttons.disabled.color
: props.theme.buttons.primary.color};
border: ${props =>
props.disabled ? 'none' : `1px solid ${props.theme.accent}`};
${props =>
props.disabled
? `background: ${props.theme.buttons.disabled.background};`
: `background-image: ${props.theme.buttons.primary.backgroundImage}; `};
background-size: 200% auto;
svg {
fill: ${props => props.theme.buttons.primary.color};
}
`;
const PrimaryButton = styled(CashtabBaseButton)`
${PrimaryButtonOrLinkCss}
`;
export const PrimaryLink = styled(CashtabBaseLink)`
${PrimaryButtonOrLinkCss}
text-decoration: none;
&:hover {
color: ${props =>
props.disabled
? props.theme.buttons.disabled.color
: props.theme.buttons.primary.color};
text-decoration: none;
}
`;
const SecondaryButtonOrLinkCss = css<{ disabled?: boolean }>`
color: ${props =>
props.disabled
? props.theme.buttons.disabled.color
: props.theme.buttons.primary.color};
border: ${props =>
props.disabled ? 'none' : `1px solid ${props.theme.secondaryAccent}`};
${props =>
props.disabled
? `background: ${props.theme.buttons.disabled.background};`
: `background-image: ${props.theme.buttons.secondary.backgroundImage}; `};
background-size: 200% auto;
svg {
fill: ${props => props.theme.buttons.secondary.color};
}
`;
const SecondaryButton = styled(CashtabBaseButton)`
${SecondaryButtonOrLinkCss}
`;
const SecondaryLink = styled(CashtabBaseLink)`
${SecondaryButtonOrLinkCss}
text-decoration: none;
&:hover {
color: ${props =>
props.disabled
? props.theme.buttons.disabled.color
: props.theme.buttons.primary.color};
text-decoration: none;
}
`;
const SvgButtonOrLinkCss = css`
border: none;
background: none;
cursor: pointer;
svg {
height: 24px;
width: 24px;
fill: ${props => props.theme.accent};
}
&:hover {
svg {
fill: ${props => props.theme.secondaryAccent};
stroke: ${props => props.theme.secondaryAccent};
path {
fill: ${props => props.theme.secondaryAccent};
}
}
}
`;
const SvgButton = styled.button`
${SvgButtonOrLinkCss}
`;
interface IconButtonProps {
name: string;
icon: ReactNode;
onClick: React.MouseEventHandler;
}
const IconButton: React.FC<IconButtonProps> = ({ name, icon, onClick }) => (
<SvgButton aria-label={name} onClick={onClick}>
{icon}
</SvgButton>
);
const SvgLink = styled(Link)`
${SvgButtonOrLinkCss}
`;
interface IconLinkState {
contactSend: string;
}
interface IconLinkProps {
name: string;
icon: ReactNode;
to: string;
state: IconLinkState;
}
const IconLink: React.FC<IconLinkProps> = ({ name, icon, to, state }) => (
<SvgLink aria-label={name} to={to} state={state}>
{icon}
</SvgLink>
);
interface CopyIconButtonProps {
name: string;
data: string;
customMsg?: string;
showToast: boolean;
}
const CopyIconButton: React.FC<CopyIconButtonProps> = ({
name,
data,
customMsg,
showToast = false,
}) => {
return (
<SvgButton
aria-label={name}
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(data);
}
if (showToast) {
const toastMsg =
typeof customMsg !== 'undefined'
? customMsg
: `"${data}" copied to clipboard`;
toast.success(toastMsg);
}
}}
>
<CopyPasteIcon />
</SvgButton>
);
};
export default PrimaryButton;
export { SecondaryButton, SecondaryLink, IconButton, IconLink, CopyIconButton };
diff --git a/cashtab/src/components/Common/Inputs.tsx b/cashtab/src/components/Common/Inputs.tsx
index e9f659195..a7e7a09e1 100644
--- a/cashtab/src/components/Common/Inputs.tsx
+++ b/cashtab/src/components/Common/Inputs.tsx
@@ -1,846 +1,854 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { useState } from 'react';
import styled from 'styled-components';
import ScanQRCode from './ScanQRCode';
import appConfig from 'config/app';
import { supportedFiatCurrencies } from 'config/CashtabSettings';
const CashtabInputWrapper = styled.div`
box-sizing: border-box;
*,
*:before,
*:after {
box-sizing: inherit;
}
width: 100%;
`;
const InputRow = styled.div<{ invalid?: boolean }>`
display: flex;
align-items: stretch;
input,
button,
select {
border: ${props =>
props.invalid
? `1px solid ${props.theme.formError}`
: `1px solid ${props.theme.border}`};
}
button,
select {
color: ${props =>
props.invalid ? props.theme.formError : props.theme.primaryText};
}
`;
const CashtabInput = styled.input<{ invalid?: boolean }>`
${props => props.disabled && `cursor: not-allowed`};
background-color: ${props => props.theme.secondaryBackground};
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
padding: 16px 12px;
border-radius: 9px;
width: 100%;
color: ${props => props.theme.primaryText};
:focus-visible {
outline: none;
}
${props => props.invalid && `border: 1px solid ${props.theme.formError}`};
`;
const ModalInputField = styled(CashtabInput)<{ invalid?: boolean }>`
background-color: transparent;
border: ${props =>
props.invalid
? `1px solid ${props.theme.formError}`
: `1px solid ${props.theme.accent} !important`};
`;
const CashtabTextArea = styled.textarea<{ height: number }>`
background-color: ${props => props.theme.secondaryBackground};
- font-size: 12px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
padding: 16px 12px;
border-radius: 9px;
width: 100%;
color: ${props => props.theme.primaryText};
:focus-visible {
outline: none;
}
height: ${props => props.height}px;
resize: none;
${props => props.disabled && `cursor: not-allowed`};
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: ${props => props.theme.accent};
border-radius: 10px;
height: 80%;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
color: ${props => props.theme.accent};
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.5);
}
`;
const LeftInput = styled(CashtabInput)`
border-radius: 9px 0 0 9px;
`;
const OnMaxBtn = styled.button<{ invalid?: boolean }>`
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
color: ${props =>
props.invalid ? props.theme.formError : props.theme.primaryText};
border-radius: 0 9px 9px 0;
background-color: ${props => props.theme.secondaryBackground};
border-left: none !important;
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
padding: 16px;
`;
const OnMaxBtnToken = styled(OnMaxBtn)`
padding: 12px;
min-width: 59px;
`;
const AliasSuffixHolder = styled(OnMaxBtn)`
cursor: auto;
`;
const CurrencyDropdown = styled.select<{ invalid?: boolean }>`
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
padding: 6px;
color: ${props =>
props.invalid ? props.theme.formError : props.theme.primaryText};
background-color: ${props => props.theme.secondaryBackground};
border-color: ${props => props.theme.border};
:focus-visible {
outline: none;
}
`;
const SendXecDropdown = styled(CurrencyDropdown)`
width: 100px;
`;
const SellPriceDropdown = styled(CurrencyDropdown)`
width: 100px;
border-radius: 0 9px 9px 0;
`;
const CurrencyOption = styled.option`
text-align: left;
background-color: ${props => props.theme.secondaryBackground};
:hover {
background-color: ${props => props.theme.primaryBackground};
}
`;
const ErrorMsg = styled.div`
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.formError};
word-break: break-all;
`;
export const InputFlex = styled.div`
display: flex;
flex-direction: column;
width: 100%;
gap: 12px;
`;
interface InputProps {
placeholder: string;
name: string;
value: null | string;
disabled?: boolean;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
error?: string | boolean;
type?: string;
}
export const Input: React.FC<InputProps> = ({
placeholder = '',
name = '',
value = '',
disabled = false,
handleInput,
error = false,
type = 'text',
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<CashtabInput
name={name}
value={value === null ? '' : value}
placeholder={placeholder}
disabled={disabled}
invalid={typeof error === 'string'}
onChange={handleInput}
type={type}
/>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
interface ModalInputProps {
placeholder: string;
name: string;
value: null | string;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
error: string | boolean;
type?: string;
}
export const ModalInput: React.FC<ModalInputProps> = ({
placeholder = '',
name = '',
value = '',
handleInput,
error = false,
type = 'text',
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<ModalInputField
name={name}
value={value === null ? '' : value}
placeholder={placeholder}
invalid={typeof error === 'string'}
onChange={e => handleInput(e)}
type={type}
/>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
const Count = styled.span<{ invalid?: boolean }>`
color: ${props =>
props.invalid ? props.theme.secondaryAccent : props.theme.primaryText};
`;
const CountHolder = styled.div`
color: ${props => props.theme.primaryText};
`;
const CountAndErrorFlex = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const TextAreaErrorMsg = styled.div`
order: 0;
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.formError};
word-break: break-all;
`;
interface TextAreaProps {
placeholder: string;
name: string;
value: string | null;
handleInput?: React.ChangeEventHandler<HTMLTextAreaElement>;
disabled?: boolean;
height?: number;
error?: string | boolean;
showCount?: boolean;
customCount?: boolean | number;
max?: string | number;
}
export const TextArea: React.FC<TextAreaProps> = ({
placeholder = '',
name = '',
value = '',
handleInput,
disabled = false,
height = 142,
error = false,
showCount = false,
customCount = false,
max = '',
}) => {
return (
<CashtabInputWrapper>
<CashtabTextArea
placeholder={placeholder}
name={name}
value={value === null ? '' : value}
height={height}
disabled={disabled}
onChange={handleInput}
/>
<CountAndErrorFlex>
<TextAreaErrorMsg>
{typeof error === 'string' ? error : ''}
</TextAreaErrorMsg>
{showCount && (
<CountHolder>
<Count invalid={typeof error === 'string'}>
{customCount !== false
? customCount
: value === null
? 0
: value.length}
</Count>
/{max}
</CountHolder>
)}
</CountAndErrorFlex>
</CashtabInputWrapper>
);
};
interface InputWithScannerProps {
placeholder: string;
name: string;
value: null | string;
disabled?: boolean;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
error: false | string;
}
export const InputWithScanner: React.FC<InputWithScannerProps> = ({
placeholder = '',
name = '',
value = '',
disabled = false,
handleInput,
error = false,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<LeftInput
name={name}
value={value === null ? '' : value}
disabled={disabled}
placeholder={placeholder}
invalid={typeof error === 'string'}
onChange={handleInput}
/>
<ScanQRCode
onScan={result =>
handleInput({
target: {
name: 'address',
value: result,
},
} as unknown as React.ChangeEvent<HTMLInputElement>)
}
/>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
interface SendXecInputProps {
name: string;
value: string | number;
selectValue: string;
inputDisabled: boolean;
selectDisabled: boolean;
fiatCode: string;
error: false | string;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
handleSelect: React.ChangeEventHandler<HTMLSelectElement>;
handleOnMax: () => void;
}
export const SendXecInput: React.FC<SendXecInputProps> = ({
name = '',
value = 0,
inputDisabled = false,
selectValue = '',
selectDisabled = false,
fiatCode = 'USD',
error = false,
handleInput,
handleSelect,
handleOnMax,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<LeftInput
placeholder="Amount"
type="number"
step="0.01"
name={name}
value={value}
onChange={handleInput}
disabled={inputDisabled}
/>
<SendXecDropdown
data-testid="currency-select-dropdown"
value={selectValue}
onChange={handleSelect}
disabled={selectDisabled}
>
<CurrencyOption data-testid="xec-option" value="XEC">
XEC
</CurrencyOption>
<CurrencyOption data-testid="fiat-option" value={fiatCode}>
{fiatCode}
</CurrencyOption>
</SendXecDropdown>
<OnMaxBtn
onClick={handleOnMax}
// Disable the onMax button if the user has fiat selected
disabled={selectValue !== appConfig.ticker || inputDisabled}
>
max
</OnMaxBtn>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
interface SendTokenInputProps {
name: string;
placeholder: string;
value: number | string;
inputDisabled?: boolean;
error: false | string;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
handleOnMax: () => void;
}
export const SendTokenInput: React.FC<SendTokenInputProps> = ({
name = '',
placeholder = '',
value = 0,
inputDisabled = false,
error = false,
handleInput,
handleOnMax,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<LeftInput
placeholder={placeholder}
name={name}
value={value}
onChange={e => handleInput(e)}
disabled={inputDisabled}
/>
<OnMaxBtnToken onClick={handleOnMax}>max</OnMaxBtnToken>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
/**
* We only render this input from bip21 input into other fields
* So, it is always disabled
* But it needs to validate for slp decimals, since this info may not
* be available until we render this component
*/
interface SendTokenBip21InputProps {
name: string;
placeholder: string;
value: string;
error?: false | string;
}
export const SendTokenBip21Input: React.FC<SendTokenBip21InputProps> = ({
name,
placeholder,
value,
error = false,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<CashtabInput
name={name}
placeholder={placeholder}
value={value}
disabled
/>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
interface ListPriceInputProps {
name: string;
placeholder: string;
value: null | number | string;
inputDisabled?: boolean;
selectValue: string;
selectDisabled: boolean;
fiatCode: string;
error: false | string;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
handleSelect: React.ChangeEventHandler<HTMLSelectElement>;
}
export const ListPriceInput: React.FC<ListPriceInputProps> = ({
name = 'listPriceInput',
placeholder = 'listPriceInput',
value = 0,
inputDisabled = false,
selectValue = '',
selectDisabled = false,
fiatCode = 'USD',
error = false,
handleInput,
handleSelect,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<LeftInput
name={name}
placeholder={placeholder}
type="number"
value={value === null ? '' : value}
onChange={handleInput}
disabled={inputDisabled}
invalid={typeof error === 'string'}
/>
<SellPriceDropdown
data-testid="currency-select-dropdown"
value={selectValue}
onChange={handleSelect}
disabled={selectDisabled}
>
<CurrencyOption data-testid="xec-option" value="XEC">
XEC
</CurrencyOption>
<CurrencyOption data-testid="fiat-option" value={fiatCode}>
{fiatCode}
</CurrencyOption>
</SellPriceDropdown>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
interface AliasInputProps {
name: string;
placeholder: string;
value: string;
inputDisabled: boolean;
error: false | string;
handleInput: React.ChangeEventHandler<HTMLInputElement>;
}
export const AliasInput: React.FC<AliasInputProps> = ({
name = '',
placeholder = '',
value = '',
inputDisabled = false,
error = false,
handleInput,
}) => {
return (
<CashtabInputWrapper>
<InputRow invalid={typeof error === 'string'}>
<LeftInput
placeholder={placeholder}
type="string"
name={name}
value={value}
onChange={e => handleInput(e)}
disabled={inputDisabled}
/>
<AliasSuffixHolder>.xec</AliasSuffixHolder>
</InputRow>
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
const CashtabSlider = styled.input<{
fixedWidth?: boolean;
isInvalid?: boolean;
}>`
width: ${props => (props.fixedWidth ? '256px' : '100%')};
accent-color: ${props =>
props.isInvalid ? props.theme.error : props.theme.accent};
`;
const SliderInput = styled.input<{ invalid?: boolean }>`
${props => props.disabled && `cursor: not-allowed`};
background-color: ${props => props.theme.secondaryBackground};
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
padding: 6px;
border-radius: 4px;
text-align: right;
border: ${props =>
props.invalid ? `1px solid ${props.theme.formError}` : `none`};
width: 100%;
color: ${props => props.theme.primaryText};
margin-top: 5px;
:focus-visible {
outline: none;
}
`;
export const SliderLabel = styled.span`
color: ${props => props.theme.primaryText};
width: 25%;
text-align: right;
line-height: 14px;
`;
export const LabelAndInputFlex = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 3px;
`;
interface SliderProps {
name: string;
value: string;
error?: false | string;
min: number | string;
max: number | string;
step: number | string;
handleSlide: React.ChangeEventHandler<HTMLInputElement>;
fixedWidth?: boolean;
allowTypedInput?: boolean;
label?: string;
disabled?: boolean;
}
export const Slider: React.FC<SliderProps> = ({
name,
value,
error = false,
min,
max,
step,
handleSlide,
fixedWidth,
allowTypedInput,
label,
disabled = false,
}) => {
return (
<CashtabInputWrapper>
<CashtabSlider
type="range"
name={name}
value={value}
min={min}
max={max}
step={step}
aria-labelledby={name}
onChange={handleSlide}
isInvalid={typeof error === 'string'}
fixedWidth={fixedWidth}
disabled={disabled}
/>
{allowTypedInput && (
<LabelAndInputFlex>
{typeof label === 'string' && (
<SliderLabel>
{label}
{':'}
</SliderLabel>
)}
<SliderInput
name={`${name}-typed`}
value={value}
placeholder={typeof label === 'string' ? label : name}
invalid={typeof error === 'string'}
onChange={handleSlide}
disabled={disabled}
></SliderInput>
</LabelAndInputFlex>
)}
<ErrorMsg>{typeof error === 'string' ? error : ''}</ErrorMsg>
</CashtabInputWrapper>
);
};
const InputFile = styled.input`
display: none;
`;
const DragForm = styled.form`
height: 16rem;
width: 100%;
max-width: 100%;
text-align: center;
position: relative;
`;
const DragLabel = styled.label<{ dragActive?: boolean }>`
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 2px dashed
${props =>
props.dragActive ? props.theme.primaryText : props.theme.accent};
background-color: ${props =>
props.dragActive ? props.theme.accent : props.theme.primaryText};
`;
const UploadText = styled.div`
cursor: pointer;
padding: 0.25rem;
- font-size: 1rem;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
border: none;
background-color: transparent;
&:hover {
text-decoration-line: underline;
}
`;
const DragText = styled.p``;
const DragHolder = styled.div``;
const DragFileElement = styled.div`
position: absolute;
width: 100%;
height: 100%;
border-radius: 1rem;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
`;
const TokenIconPreview = styled.img`
width: 242px;
height: 242px;
`;
interface CashtabDraggerProps {
name: string;
handleFile: (file: File) => void;
imageUrl: string;
nft?: boolean;
}
export const CashtabDragger: React.FC<CashtabDraggerProps> = ({
name,
handleFile,
imageUrl,
nft = false,
}) => {
// drag state
const [dragActive, setDragActive] = useState(false);
// handle drag events
const handleDrag = (e: React.DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
// Update component state for drag enter
setDragActive(true);
} else if (e.type === 'dragleave') {
// Update component state for drag exit
setDragActive(false);
}
};
const handleDrop = function (e: React.DragEvent<HTMLElement>) {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
// User adds file by clicking the component
const handleChange = function (e: React.ChangeEvent<HTMLInputElement>) {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFile(e.target.files[0]);
}
};
return (
<DragForm
id="form-file-upload"
onDragEnter={handleDrag}
onSubmit={e => e.preventDefault()}
>
<InputFile
name={name}
type="file"
id="input-file-upload"
multiple={false}
onChange={handleChange}
/>
<DragLabel
id="label-file-upload"
htmlFor="input-file-upload"
dragActive={dragActive}
>
{imageUrl ? (
<TokenIconPreview src={imageUrl} alt="token icon" />
) : (
<DragHolder>
<DragText>
Drag and drop a png or jpg for your{' '}
{nft ? 'NFT' : 'token icon'}
</DragText>
<UploadText>or click to upload</UploadText>
</DragHolder>
)}
</DragLabel>
{dragActive && (
<DragFileElement
id="drag-file-element"
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
></DragFileElement>
)}
</DragForm>
);
};
interface CurrencySelectProps {
name: string;
value: string;
handleSelect: React.ChangeEventHandler<HTMLSelectElement>;
}
interface CurrencyMenuOption {
value?: string;
label?: string;
}
export const CurrencySelect: React.FC<CurrencySelectProps> = ({
name = 'select',
value,
handleSelect,
}) => {
// Build select dropdown from supportedFiatCurrencies
const currencyMenuOptions: CurrencyMenuOption[] = [];
const currencyKeys = Object.keys(supportedFiatCurrencies);
for (let i = 0; i < currencyKeys.length; i += 1) {
const currencyMenuOption: CurrencyMenuOption = {};
currencyMenuOption.value =
supportedFiatCurrencies[currencyKeys[i]].slug;
currencyMenuOption.label = `${
supportedFiatCurrencies[currencyKeys[i]].name
} (${supportedFiatCurrencies[currencyKeys[i]].symbol})`;
currencyMenuOptions.push(currencyMenuOption);
}
const currencyOptions = currencyMenuOptions.map(currencyMenuOption => {
return (
<CurrencyOption
key={currencyMenuOption.value}
value={currencyMenuOption.value}
data-testid={currencyMenuOption.value}
>
{currencyMenuOption.label}
</CurrencyOption>
);
});
return (
<CurrencyDropdown
data-testid={name}
value={value}
onChange={handleSelect}
>
{currencyOptions}
</CurrencyDropdown>
);
};
diff --git a/cashtab/src/components/Common/Modal.tsx b/cashtab/src/components/Common/Modal.tsx
index b82567779..76e32c741 100644
--- a/cashtab/src/components/Common/Modal.tsx
+++ b/cashtab/src/components/Common/Modal.tsx
@@ -1,189 +1,193 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled from 'styled-components';
import { CashtabScroll } from './Atoms';
const ModalContainer = styled.div<{ width: number; height: number }>`
width: ${props => props.width}px;
height: ${props => props.height}px;
transition: height 1s ease-in-out;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 100%;
max-height: 100%;
border-radius: 9px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
padding: 12px;
z-index: 1000;
box-sizing: border-box;
*,
*:before,
*:after {
box-sizing: inherit;
}
`;
const ModalTitle = styled.div`
font-weight: bold;
padding: 6px 0;
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
text-align: center;
width: 100%;
color: ${props => props.theme.accent};
`;
const MODAL_HEIGHT_DELTA = 68;
const ModalBody = styled.div<{ showButtons: boolean; height: number }>`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: ${props =>
props.showButtons ? props.height - MODAL_HEIGHT_DELTA : props.height}px;
transition: height 1s ease-in-out;
overflow: auto;
padding: 6px;
word-wrap: break-word;
${CashtabScroll}
`;
const ModalDescription = styled.div`
color: ${props => props.theme.primaryText};
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
margin: 12px 0;
text-align: center;
`;
const ButtonHolder = styled.div`
width: 100%;
position: fixed;
left: 50%;
bottom: 0;
display: flex;
justify-content: center;
gap: 24px;
left: 50%;
bottom: 0;
transform: translate(-50%, -50%);
`;
const ModalBaseButton = styled.button`
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
padding: 8px 0;
border-radius: 9px;
transition: all 0.5s ease;
width: 100px;
cursor: pointer;
background-size: 200% auto;
:hover {
background-position: right center;
-webkit-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
-moz-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
}
`;
const ModalConfirm = styled(ModalBaseButton)`
color: ${props =>
props.disabled
? props.theme.buttons.disabled.color
: props.theme.buttons.primary.color};
border: 1px solid ${props => (props.disabled ? 'none' : props.theme.accent)};
${props =>
props.disabled
? `background: ${props.theme.buttons.disabled.background};`
: `background-image: ${props.theme.buttons.primary.backgroundImage}; `};
background-size: 200% auto;
`;
const ModalCancel = styled(ModalBaseButton)`
color: ${props => props.theme.buttons.primary.color};
border: 1px solid ${props => props.theme.secondaryAccent};
background-image: ${props => props.theme.buttons.secondary.backgroundImage};
background-size: 200% auto;
:hover {
color: ${props => props.theme.buttons.primary.color};
background-color: ${props => props.theme.secondaryAccent + '60'};
}
`;
const ModalExit = styled.button`
position: absolute;
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
z-index: 1001;
right: 5px;
top: 5px;
background: none;
border: none !important;
color: ${props => props.theme.primaryText};
font-weight: bold;
cursor: pointer;
:hover {
color: ${props => props.theme.secondaryAccent};
}
`;
const Overlay = styled.div`
z-index: 999;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
interface ModalProps {
title?: string;
description?: string;
handleOk?: () => void;
handleCancel: () => void;
showCancelButton?: boolean;
children?: React.ReactNode;
width?: number;
height?: number;
showButtons?: boolean;
disabled?: boolean;
}
export const Modal: React.FC<ModalProps> = ({
title,
description,
handleOk,
handleCancel,
showCancelButton = false,
children,
width = 320,
height = 210,
showButtons = true,
disabled = false,
}) => {
return (
<>
<ModalContainer width={width} height={height}>
<ModalExit onClick={handleCancel}>X</ModalExit>
<ModalBody height={height} showButtons={showButtons}>
{typeof title !== 'undefined' && (
<ModalTitle>{title}</ModalTitle>
)}
{typeof description !== 'undefined' && (
<ModalDescription>{description}</ModalDescription>
)}
{children}
</ModalBody>
{showButtons && (
<ButtonHolder>
<ModalConfirm disabled={disabled} onClick={handleOk}>
OK
</ModalConfirm>
{showCancelButton && (
<ModalCancel onClick={handleCancel}>
Cancel
</ModalCancel>
)}
</ButtonHolder>
)}
</ModalContainer>
<Overlay />
</>
);
};
export default Modal;
diff --git a/cashtab/src/components/Common/Seed.js b/cashtab/src/components/Common/Seed.js
index 8b5b9b043..9919f43de 100644
--- a/cashtab/src/components/Common/Seed.js
+++ b/cashtab/src/components/Common/Seed.js
@@ -1,50 +1,51 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
const SeedHolder = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
`;
const SeedRow = styled.code`
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
`;
const CASHTAB_SEED_WORDCOUNT = 12;
const CASHTAB_SEED_SLICE_SIZE = 4;
const Seed = ({ mnemonic }) => {
const seedArray = mnemonic.split(' ');
const rowArray = [];
for (
let i = 0;
i < CASHTAB_SEED_WORDCOUNT / CASHTAB_SEED_SLICE_SIZE;
i += 1
) {
rowArray.push(
seedArray.slice(
i * CASHTAB_SEED_SLICE_SIZE,
i * CASHTAB_SEED_SLICE_SIZE + CASHTAB_SEED_SLICE_SIZE,
),
);
}
return (
<SeedHolder className="no-translate">
{rowArray.map((row, index) => {
return <SeedRow key={index}>{row.join(' ')}</SeedRow>;
})}
</SeedHolder>
);
};
Seed.propTypes = {
mnemonic: PropTypes.string.isRequired,
};
export default Seed;
diff --git a/cashtab/src/components/Common/Switch.tsx b/cashtab/src/components/Common/Switch.tsx
index 5b5a02ed0..86c2473b7 100644
--- a/cashtab/src/components/Common/Switch.tsx
+++ b/cashtab/src/components/Common/Switch.tsx
@@ -1,160 +1,160 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled from 'styled-components';
const Container = styled.div<{ switchWidth?: number }>`
width: ${props => props.switchWidth}px;
`;
const ToggleSwitch = styled.div<{ switchWidth?: number }>`
position: relative;
width: ${props => props.switchWidth}px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
text-align: left;
`;
const SwitchLabel = styled.label<{ disabled?: boolean }>`
display: block;
overflow: hidden;
cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
border: 0 solid #ccc;
border-radius: 20px;
margin: 0;
`;
const SwitchInner = styled.span<{
bgImageOn: boolean | string;
small: boolean;
bgImageOff: boolean | string;
bgColorOff: boolean | string;
}>`
display: block;
width: 200%;
margin-left: -100%;
&:before {
content: attr(data-on);
${props =>
props.bgImageOn
? `background: ${props.theme.accent} url(${props.bgImageOn}) 20%/contain no-repeat`
: `background-color: ${props.theme.accent}`};
text-transform: uppercase;
padding-left: 10px;
color: #fff;
}
&::before,
&::after {
display: block;
float: left;
width: 50%;
height: ${props => (props.small ? '20' : '34')}px;
line-height: ${props => (props.small ? '20' : '34')}px;
- font-size: 14px;
+ font-size: var(--text-sm);
color: white;
font-weight: bold;
box-sizing: border-box;
}
&::after {
content: attr(data-off);
${props =>
props.bgImageOff
? `background: ${props.bgColorOff} url(${props.bgImageOff}) 80%/contain no-repeat`
: `background-color: ${props.bgColorOff}`};
text-transform: uppercase;
padding-right: 10px;
color: #fff;
text-align: right;
}
`;
const SwitchItself = styled.span<{ small?: boolean; right?: number }>`
display: block;
width: ${props => (props.small ? '10' : '24')}px;
margin: 5px;
background: #fff;
position: absolute;
top: 0;
bottom: 0;
right: ${props => props.right}px;
border: 0 solid #ccc;
border-radius: 20px;
transition: all 0.42s ease-in 0s;
`;
const SwitchInput = styled.input`
display: none;
&:checked + ${SwitchLabel} ${SwitchInner} {
margin-left: 0;
}
&:checked + ${SwitchLabel} ${SwitchItself} {
right: 0px;
}
`;
interface CashtabSwitchProps {
name: string;
small?: boolean;
width?: number;
right?: number;
on?: string;
bgImageOn?: string;
off?: string;
bgImageOff?: string;
bgColorOff?: string;
checked: boolean;
disabled?: boolean;
handleToggle: (
e: React.ChangeEvent<HTMLInputElement>,
) => void | (() => void);
}
export const CashtabSwitch: React.FC<CashtabSwitchProps> = ({
name,
small = false,
width,
right,
on = '',
bgImageOn = false,
off = '',
bgImageOff = false,
bgColorOff = '#908e8e',
checked,
disabled,
handleToggle,
}) => {
if (typeof width === 'undefined') {
// If width is not specified, use default for small or normal switch
width = small ? 42 : 75;
}
if (typeof right === 'undefined') {
// If right is not specified, use default for small or normal switch
right = small ? 20 : 42;
}
return (
<Container>
<ToggleSwitch switchWidth={width}>
<SwitchInput
title={name}
type="checkbox"
checked={checked}
onChange={handleToggle}
disabled={disabled}
name={name}
id={name}
/>
<SwitchLabel htmlFor={name} disabled={disabled}>
<SwitchInner
data-on={on}
data-off={off}
bgImageOn={bgImageOn}
bgImageOff={bgImageOff}
bgColorOff={bgColorOff}
small={small}
></SwitchInner>
<SwitchItself right={right} small={small}></SwitchItself>
</SwitchLabel>
</ToggleSwitch>
</Container>
);
};
export default CashtabSwitch;
diff --git a/cashtab/src/components/Common/WalletLabel.tsx b/cashtab/src/components/Common/WalletLabel.tsx
index 814d1af8a..401b6bd29 100644
--- a/cashtab/src/components/Common/WalletLabel.tsx
+++ b/cashtab/src/components/Common/WalletLabel.tsx
@@ -1,148 +1,149 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled from 'styled-components';
import { getWalletsForNewActiveWallet } from 'wallet';
import { getTextWidth } from 'helpers';
import WalletHeaderActions from 'components/Common/WalletHeaderActions';
import CashtabSettings from 'config/CashtabSettings';
import { CashtabWallet } from 'wallet';
import { UpdateCashtabState } from 'wallet/useWallet';
import CashtabState from 'config/CashtabState';
const LabelCtn = styled.div`
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 3%;
transition: all ease-in-out 200ms;
svg {
height: 21px;
width: 21px;
}
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
@media (max-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0 20px;
}
`;
const EXTRA_WIDTH_FOR_SELECT = 32;
const WalletDropdown = styled.select<{ value: string }>`
font-family: 'Poppins', 'Ubuntu', -apple-system, BlinkMacSystemFont,
'Segoe UI', 'Roboto', 'Oxygen', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
width: ${props =>
`${
getTextWidth(document, props.value, '18px Poppins') +
EXTRA_WIDTH_FOR_SELECT
}px`};
cursor: pointer;
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
padding: 6px;
color: ${props => props.theme.primaryText};
border: none;
border-radius: 9px;
background-color: transparent;
transition: width 0.2s;
text-overflow: ellipsis;
&:focus-visible {
outline: none;
text-decoration: underline;
}
`;
const WalletOption = styled.option`
text-align: left;
background-color: ${props => props.theme.secondaryBackground};
:hover {
color: ${props => props.theme.accent};
background-color: ${props => props.theme.primaryBackground};
}
`;
interface WalletLabelProps {
wallets: CashtabWallet[];
settings: CashtabSettings;
updateCashtabState: UpdateCashtabState;
setCashtabState: React.Dispatch<React.SetStateAction<CashtabState>>;
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}
const WalletLabel: React.FC<WalletLabelProps> = ({
wallets,
settings,
updateCashtabState,
setCashtabState,
loading,
setLoading,
}) => {
const address = wallets[0].paths.get(1899).address;
const handleSelectWallet = (e: React.ChangeEvent<HTMLSelectElement>) => {
const walletName = e.target.value;
// Get the active wallet by name
const walletToActivate = wallets.find(
wallet => wallet.name === walletName,
);
if (typeof walletToActivate === 'undefined') {
return;
}
// Get desired wallets array after activating walletToActivate
const walletsAfterActivation = getWalletsForNewActiveWallet(
walletToActivate,
wallets,
);
/**
* Update state
* useWallet.ts has a useEffect that will then sync this new
* active wallet with the network and update it in storage
*
* We also setLoading(true) on a wallet change, because we want
* to prevent rapid wallet cycling
*
* setLoading(false) is called after the wallet is updated in useWallet.ts
*/
setLoading(true);
setCashtabState(prevState => ({
...prevState,
wallets: walletsAfterActivation,
}));
};
return (
<LabelCtn>
<WalletDropdown
name="wallets"
id="wallets"
data-testid="wallet-select"
onChange={e => handleSelectWallet(e)}
value={wallets[0].name}
disabled={loading}
>
{wallets.map((wallet, index) => (
<WalletOption key={index} value={wallet.name}>
{wallet.name}
</WalletOption>
))}
</WalletDropdown>
<WalletHeaderActions
address={address}
settings={settings}
updateCashtabState={updateCashtabState}
/>
</LabelCtn>
);
};
export default WalletLabel;
diff --git a/cashtab/src/components/Configure/Configure.tsx b/cashtab/src/components/Configure/Configure.tsx
index c5c5d40fc..7413404e0 100644
--- a/cashtab/src/components/Configure/Configure.tsx
+++ b/cashtab/src/components/Configure/Configure.tsx
@@ -1,238 +1,239 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { useContext } from 'react';
import styled from 'styled-components';
import { WalletContext, isWalletContextLoaded } from 'wallet/context';
import {
DollarIcon,
SettingsIcon,
ThemedXIcon,
ThemedFacebookIcon,
SocialContainer,
SocialLink,
GithubIcon,
} from 'components/Common/CustomIcons';
import TokenIcon from 'components/Etokens/TokenIcon';
import appConfig from 'config/app';
import { hasEnoughToken } from 'wallet';
import { CurrencySelect } from 'components/Common/Inputs';
import Switch from 'components/Common/Switch';
import { PageHeader } from 'components/Common/Atoms';
const VersionContainer = styled.div`
color: ${props => props.theme.primaryText};
`;
const ConfigIconWrapper = styled.div`
svg {
height: 42px;
width: 42px;
fill: ${props => props.theme.accent};
}
`;
const StyledConfigure = styled.div`
margin: 12px 0;
background: ${props => props.theme.primaryBackground};
padding: 20px;
border-radius: 10px;
@media (max-width: 768px) {
border-radius: 0;
margin: 0;
}
h2 {
margin-bottom: 30px;
}
`;
const HeadlineAndIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin: 12px 0;
`;
const Headline = styled.div`
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
color: ${props => props.theme.primaryText};
font-weight: bold;
`;
const StyledSpacer = styled.div`
height: 1px;
width: 100%;
background-color: ${props => props.theme.border};
margin: 60px 0 50px;
`;
const SettingsLabel = styled.div`
text-align: left;
display: flex;
gap: 9px;
color: ${props => props.theme.primaryText};
`;
const Switches = styled.div`
flex-direction: column;
display: flex;
gap: 12px;
`;
const GeneralSettingsItem = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
`;
const Configure: React.FC = () => {
const ContextValue = useContext(WalletContext);
if (!isWalletContextLoaded(ContextValue)) {
// Confirm we have all context required to load the page
return null;
}
const { updateCashtabState, cashtabState } = ContextValue;
const { settings, wallets } = cashtabState;
const wallet = wallets[0];
const { tokens } = wallet.state;
const handleSendModalToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
updateCashtabState('settings', {
...settings,
sendModal: e.target.checked,
});
};
const handleMinFeesToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
updateCashtabState('settings', {
...settings,
minFeeSends: e.target.checked,
});
};
return (
<StyledConfigure title="Settings">
<PageHeader>
Settings <SettingsIcon />
</PageHeader>
<HeadlineAndIcon>
<ConfigIconWrapper>
<DollarIcon />
</ConfigIconWrapper>{' '}
<Headline>Fiat Currency</Headline>
</HeadlineAndIcon>
<CurrencySelect
name="configure-fiat-select"
value={cashtabState.settings.fiatCurrency}
handleSelect={e => {
updateCashtabState('settings', {
...settings,
fiatCurrency: e.target.value,
});
}}
/>
<StyledSpacer />
<HeadlineAndIcon>
<ConfigIconWrapper>
<SettingsIcon />
</ConfigIconWrapper>{' '}
<Headline>General Settings</Headline>
</HeadlineAndIcon>
<Switches>
<GeneralSettingsItem>
<Switch
name="Toggle Send Confirmations"
checked={settings.sendModal}
handleToggle={handleSendModalToggle}
/>
<SettingsLabel>Send Confirmations</SettingsLabel>
</GeneralSettingsItem>
</Switches>
{(hasEnoughToken(
tokens,
appConfig.vipTokens.grumpy.tokenId,
appConfig.vipTokens.grumpy.vipBalance,
) ||
hasEnoughToken(
tokens,
appConfig.vipTokens.cachet.tokenId,
appConfig.vipTokens.cachet.vipBalance,
)) && (
<>
<StyledSpacer />
<HeadlineAndIcon>
{' '}
{hasEnoughToken(
tokens,
appConfig.vipTokens.grumpy.tokenId,
appConfig.vipTokens.grumpy.vipBalance,
) && (
<TokenIcon
size={64}
tokenId={appConfig.vipTokens.grumpy.tokenId}
/>
)}
{hasEnoughToken(
tokens,
appConfig.vipTokens.cachet.tokenId,
appConfig.vipTokens.cachet.vipBalance,
) && (
<TokenIcon
size={64}
tokenId={appConfig.vipTokens.cachet.tokenId}
/>
)}
<Headline>VIP Settings</Headline>
</HeadlineAndIcon>
<GeneralSettingsItem>
<Switch
name="Toggle minimum fee sends"
checked={settings.minFeeSends}
handleToggle={handleMinFeesToggle}
/>
<SettingsLabel> ABSOLUTE MINIMUM fees</SettingsLabel>
</GeneralSettingsItem>
</>
)}
<StyledSpacer />
<SocialContainer>
<SocialLink
href="https://x.com/cashtabwallet"
target="_blank"
rel="noreferrer"
>
<ThemedXIcon />
</SocialLink>{' '}
<SocialLink
href="https://www.facebook.com/Cashtab"
target="_blank"
rel="noreferrer"
>
<ThemedFacebookIcon />
</SocialLink>
<SocialLink
href="https://github.com/Bitcoin-ABC/bitcoin-abc/tree/master/cashtab"
target="_blank"
rel="noreferrer"
>
<GithubIcon />
</SocialLink>
</SocialContainer>
{typeof process.env.REACT_APP_VERSION === 'string' && (
<>
<StyledSpacer />
<VersionContainer>
v{process.env.REACT_APP_VERSION}
</VersionContainer>
</>
)}
</StyledConfigure>
);
};
export default Configure;
diff --git a/cashtab/src/components/Etokens/CreateTokenForm/styles.ts b/cashtab/src/components/Etokens/CreateTokenForm/styles.ts
index f03b59c52..66ef8a676 100644
--- a/cashtab/src/components/Etokens/CreateTokenForm/styles.ts
+++ b/cashtab/src/components/Etokens/CreateTokenForm/styles.ts
@@ -1,95 +1,97 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
export const Form = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
export const SwitchRow = styled.div`
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
`;
export const SwitchLabel = styled.div`
text-align: left;
color: ${props => props.theme.primaryText};
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
`;
export const EditIcon = styled.div`
cursor: pointer;
color: ${props => props.theme.primaryText};
&:hover {
color: ${props => props.theme.accent};
}
word-wrap: break-word;
`;
export const IconModalForm = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
`;
export const IconModalRow = styled.div`
display: flex;
width: 100%;
gap: 3px;
`;
export const SliderLabel = styled.div`
color: ${props => props.theme.primaryText};
`;
export const SliderBox = styled.div`
width: 100%;
`;
export const CropperContainer = styled.div`
height: 200px;
position: relative;
`;
export const CreateTokenTitle = styled.h3`
color: ${props => props.theme.primaryText};
`;
export const TokenCreationSummaryTable = styled.div`
color: ${props => props.theme.primaryText};
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 3px;
`;
export const SummaryRow = styled.div`
display: flex;
gap: 12px;
justify-content: flex-start;
align-items: start;
width: 100%;
`;
export const TokenParam = styled.div`
word-break: break-word;
`;
export const ButtonDisabledMsg = styled.div`
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.formError};
word-break: break-all;
`;
export const TokenTypeDescription = styled.div`
display: flex;
gap: 12px;
flex-direction: row;
flex-wrap: wrap;
`;
export const TokenInfoParagraph = styled.div`
display: flex;
width: 100%;
text-align: justify;
padding: 0 6px;
color: ${props => props.theme.primaryText};
`;
export const OuterCtn = styled.div`
background-color: ${props => props.theme.primaryBackground};
padding: 20px;
border-radius: 10px;
`;
diff --git a/cashtab/src/components/Etokens/Token/styled.ts b/cashtab/src/components/Etokens/Token/styled.ts
index fc971c8ff..b551c141e 100644
--- a/cashtab/src/components/Etokens/Token/styled.ts
+++ b/cashtab/src/components/Etokens/Token/styled.ts
@@ -1,316 +1,321 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
export const OuterCtn = styled.div`
background-color: ${props => props.theme.primaryBackground};
padding: 20px;
border-radius: 10px;
max-width: 700px;
margin: auto;
`;
export const TokenScreenWrapper = styled.div`
color: ${props => props.theme.primaryText};
width: 100%;
h2 {
margin: 0 0 20px;
margin-top: 10px;
}
`;
export const InfoModalParagraph = styled.p`
color: ${props => props.theme.primaryText};
text-align: left;
`;
export const DataAndQuestionButton = styled.div`
display: flex;
align-items: center;
`;
export const TokenIconExpandButton = styled.button`
cursor: pointer;
border: none;
background-color: transparent;
`;
export const SendTokenForm = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 12px;
`;
export const SendTokenFormRow = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
gap: 12px;
margin: 3px;
`;
export const InputRow = styled.div`
width: 100%;
`;
export const TokenStatsTable = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
color: ${props => props.theme.primaryText};
border-radius: 20px 20px 0 0;
padding: 20px;
border: 1px solid ${props => props.theme.border};
border-bottom: none;
background: linear-gradient(
0deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 100%
);
`;
export const TokenStatsRowCtn = styled.div`
width: 100%;
display: flex;
flex-direction: column;
border-top: 1px solid ${props => props.theme.border};
border-bottom: 1px solid ${props => props.theme.border};
padding: 20px 0;
margin-top: 20px;
`;
export const TokenStatsRow = styled.div`
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
text-align: center;
justify-content: center;
gap: 3px;
`;
export const TokenStatsCol = styled.div`
align-items: center;
justify-content: center;
display: flex;
flex-direction: column;
h2 {
margin: 0;
- font-size: 26px;
- line-height: 1.2em;
+ font-size: var(--text-2xl);
+ line-height: var(--text-2xl--line-height);
font-weight: 600;
text-align: center;
}
span {
color: ${props => props.theme.secondaryText};
}
`;
export const TokenUrlCol = styled(TokenStatsCol)`
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 300px;
@media (max-width: 768px) {
max-width: 200px;
}
@media (max-width: 400px) {
max-width: 124px;
}
`;
export const TokenStatsTableRow = styled.div<{ balance?: boolean }>`
width: 100%;
display: flex;
align-items: flex-start;
gap: 20px;
justify-content: space-between;
color: ${props =>
props.balance ? props.theme.primaryText : props.theme.secondaryText};
font-size: ${props => (props.balance ? '18px' : '16px')};
@media (max-width: 768px) {
font-size: ${props => (props.balance ? '16px' : '14px')};
}
margin-bottom: ${props => (props.balance ? '15px' : '0')};
label {
flex-shrink: 0;
}
> div {
display: flex;
align-items: center;
word-break: break-all;
text-align: right;
button {
display: flex;
align-items: center;
padding: 0;
padding-left: 5px;
}
a {
color: ${props => props.theme.secondaryText};
:hover {
color: ${props => props.theme.accent};
text-decoration: underline;
}
}
svg {
width: 16px;
height: 16px;
g {
fill: ${props => props.theme.secondaryText};
}
fill: ${props => props.theme.secondaryText};
:hover {
g {
fill: ${props => props.theme.secondaryAccent};
}
fill: ${props => props.theme.secondaryAccent};
}
}
}
`;
export const TokenStatsLabel = styled.div`
font-weight: bold;
justify-content: flex-end;
text-align: right;
display: flex;
width: 106px;
`;
export const SwitchHolder = styled.div`
width: 100%;
display: flex;
justify-content: flex-start;
gap: 12px;
align-content: center;
align-items: center;
margin: 12px;
`;
export const ButtonDisabledMsg = styled.div`
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.formError};
word-break: break-all;
`;
export const ButtonDisabledSpan = styled.span`
color: ${props => props.theme.formError};
`;
export const NftTitle = styled.div`
color: ${props => props.theme.primaryText};
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
text-align: center;
font-weight: bold;
`;
export const NftTable = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 9px;
width: 100%;
background-color: ${props => props.theme.secondaryBackground}
border-radius: 9px;
color: ${props => props.theme.primaryText};
max-height: 220px;
overflow: auto;
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
background-color: ${props => props.theme.accent};
border-radius: 10px;
height: 80%;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
color: ${props => props.theme.accent};
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.5);
}
`;
export const NftRow = styled.div`
display: flex;
flex-direction: row;
gap: 3px;
align-items: center;
justify-content: center;
`;
export const NftTokenIdAndCopyIcon = styled.div`
display: flex;
align-items: center;
svg {
width: 18px;
height: 18px;
:hover {
g {
fill: ${props => props.theme.secondaryAccent};
}
fill: ${props => props.theme.secondaryAccent};
}
}
`;
export const NftCol = styled.div`
display: flex;
flex-direction: column;
svg {
width: 18px;
height: 18px;
}
gap: 6px;
`;
export const NftNameTitle = styled.div`
margin-top: 12px;
- font-size: 24px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
font-weight: bold;
color: ${props => props.theme.primaryText};
word-break: break-all;
`;
export const NftCollectionTitle = styled.div`
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
color: ${props => props.theme.primaryText};
word-break: break-all;
`;
export const ListPricePreview = styled.div`
text-align: center;
color: ${props => props.theme.primaryText};
`;
export const AgoraPreviewParagraph = styled.p`
color: ${props => props.theme.primaryText};
`;
export const AgoraPreviewTable = styled.div`
display: flex;
flex-wrap: wrap;
width: 100%;
- font-size: 12px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.primaryText};
`;
export const AgoraPreviewRow = styled.div`
display: flex;
justify-content: center;
gap: 3px;
align-items: center;
width: 100%;
flex-direction: row;
`;
export const AgoraPreviewCol = styled.div`
display: flex;
flex-direction: column;
`;
export const AgoraPreviewLabel = styled.div`
display: flex;
flex-direction: column;
font-weight: bold;
text-align: right;
`;
export const NftOfferWrapper = styled.div`
color: ${props => props.theme.primaryText};
border-radius: 0 0 20px 20px;
border: 1px solid ${props => props.theme.border};
border-top: none;
div {
margin-top: 0px;
margin-bottom: 0px;
border-radius: 0 0 20px 20px;
}
`;
diff --git a/cashtab/src/components/Etokens/TokenListItem.tsx b/cashtab/src/components/Etokens/TokenListItem.tsx
index 2fcb9e1d6..76006da75 100644
--- a/cashtab/src/components/Etokens/TokenListItem.tsx
+++ b/cashtab/src/components/Etokens/TokenListItem.tsx
@@ -1,87 +1,88 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React from 'react';
import styled from 'styled-components';
import TokenIcon from './TokenIcon';
import { decimalizedTokenQtyToLocaleFormat } from 'formatting';
import { CashtabCachedTokenInfo } from 'config/CashtabCache';
const TokenIconWrapper = styled.div`
margin-right: 10px;
`;
const TokenNameCtn = styled.div`
display: flex;
align-items: center;
`;
const Wrapper = styled.div`
display: flex;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
color: ${props => props.theme.primaryText};
padding: 10px 0;
justify-content: space-between;
h4 {
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
color: ${props => props.theme.primaryText};
margin: 0;
font-weight: bold;
}
:hover {
h4 {
color: ${props => props.theme.secondaryAccent};
}
}
`;
/**
* We add balance key to token info for tokens we have
* This is to keep data together for each token that we use for sorting on this page
*/
export interface ExtendedCashtabCachedTokenInfo extends CashtabCachedTokenInfo {
balance: string;
}
interface TokenListItemProps {
tokenId: string;
tokenInfo: ExtendedCashtabCachedTokenInfo;
userLocale: string;
}
/**
* Display token ticker and balance as a table item
* All tokens should be cached when this screen is rendered
* But, in case it isn't for any reason, handle this case
*/
const TokenListItem: React.FC<TokenListItemProps> = ({
tokenId,
tokenInfo,
userLocale,
}) => {
return (
<Wrapper title="Token List Item">
<TokenNameCtn>
<TokenIconWrapper>
<TokenIcon size={32} tokenId={tokenId} />
</TokenIconWrapper>
<h4>
{typeof tokenInfo !== 'undefined'
? tokenInfo.genesisInfo.tokenTicker
: 'UNCACHED'}
</h4>
</TokenNameCtn>
<h4>
{decimalizedTokenQtyToLocaleFormat(
tokenInfo.balance,
userLocale,
)}
</h4>
</Wrapper>
);
};
export default TokenListItem;
diff --git a/cashtab/src/components/Home/Tx/styled.ts b/cashtab/src/components/Home/Tx/styled.ts
index 684e7b9bb..cce677f12 100644
--- a/cashtab/src/components/Home/Tx/styled.ts
+++ b/cashtab/src/components/Home/Tx/styled.ts
@@ -1,228 +1,229 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import { XecTxType } from 'chronik';
export const TxWrapper = styled.div`
border-bottom: 1px solid ${props => props.theme.border};
display: flex;
flex-direction: column;
gap: 12px;
svg {
width: 33px;
height: 33px;
}
img {
height: 33px;
}
box-sizing: border-box;
*,
*:before,
*:after {
box-sizing: inherit;
}
`;
export const Collapse = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
cursor: pointer;
`;
const Incoming = css`
color: ${props => props.theme.accent};
fill: ${props => props.theme.accent};
`;
const Genesis = css`
color: ${props => props.theme.genesisGreen};
fill: ${props => props.theme.genesisGreen};
svg {
fill: ${props => props.theme.genesisGreen};
}
path {
fill: ${props => props.theme.genesisGreen};
}
g {
fill: ${props => props.theme.genesisGreen};
}
`;
const Burn = css`
color: ${props => props.theme.secondaryAccent};
fill: ${props => props.theme.secondaryAccent};
svg {
fill: ${props => props.theme.secondaryAccent};
}
path {
fill: ${props => props.theme.secondaryAccent};
}
g {
fill: ${props => props.theme.secondaryAccent};
}
`;
export const MainRow = styled.div<{
type?: XecTxType;
}>`
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
gap: 12px;
width: 100%;
color: ${props => props.theme.primaryText};
fill: ${props => props.theme.primaryText};
${props =>
(props.type === 'Received' ||
props.type === 'Staking Reward' ||
props.type === 'Coinbase Reward') &&
Incoming}
`;
export const MainRowLeft = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
export const AppTxIcon = styled.div``;
export const TxDescCol = styled.div`
flex-direction: row;
`;
// Top row of TxDescCol
export const TxDescSendRcvMsg = styled.div`
display: inline-block;
`;
export const TxDesc = styled.div`
display: flex;
flex-wrap: wrap;
text-align: left;
width: 100%;
align-items: center;
gap: 6px;
`;
// Bottom row of TxDescCol
export const Timestamp = styled.div`
display: flex;
width: 100%;
text-align: left;
- font-size: 14px;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
color: ${props => props.theme.secondaryText};
`;
export const AmountCol = styled.div`
flex-direction: row;
justify-content: flex-end;
`;
// Top row of TxAmountCol
export const AmountTop = styled.div`
display: flex;
width: 100%;
justify-content: flex-end;
`;
export const AmountBottom = styled.div`
display: flex;
width: 100%;
color: ${props => props.theme.secondaryText};
justify-content: flex-end;
`;
export const CashtabMsg = styled.div`
display: flex;
width: 100%;
`;
export const TokenEntry = styled.div`
display: flex;
width: 100%;
`;
// Button panel for actions on each tx
export const Expand = styled.div<{ showPanel: boolean }>`
display: flex;
overflow: hidden;
height: ${props => (props.showPanel ? '36px' : '0px')};
visibility: ${props => (props.showPanel ? 'visible' : 'collapse')};
transition: all 0.5s ease-out;
justify-content: flex-end;
align-items: center;
gap: 12px;
svg {
height: 33px;
width: 33px;
fill: ${props => props.theme.primaryText};
}
path {
fill: ${props => props.theme.primaryText};
}
g {
fill: ${props => props.theme.primaryText};
}
`;
export const ExpandButtonPanel = styled.div`
display: flex;
`;
export const PanelButton = styled.button`
border: none;
background: none;
cursor: pointer;
`;
export const PanelLink = styled(Link)`
border: none;
background: none;
cursor: pointer;
`;
export const ReplyLink = styled(PanelLink)`
margin-left: auto;
`;
export const AddressLink = styled.a`
padding: 0 3px;
`;
export const AppAction = styled.div<{ type?: string }>`
display: flex;
gap: 12px;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 3px 12px;
${props => props.type === 'Received' && Incoming}
border-radius: 9px;
background-color: ${props => props.theme.secondaryBackground};
flex-wrap: wrap;
word-break: break-all;
`;
export const AppDescLabel = styled.div<{ noWordBreak?: boolean }>`
font-weight: bold;
word-break: ${props => (props.noWordBreak ? 'normal' : 'break-all')};
`;
export const IconAndLabel = styled.div`
display: flex;
gap: 6px;
align-items: center;
`;
export const AppDescMsg = styled.div`
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-all;
text-align: left;
`;
export const TokenAction = styled(AppAction)<{
tokenTxType?: string;
}>`
${props => props.tokenTxType === 'Received' && Incoming}
${props =>
(props.tokenTxType === 'Created' || props.tokenTxType === 'Minted') &&
Genesis}
${props => props.tokenTxType === 'Burned' && Burn}
`;
export const TokenActionHolder = styled.div``;
export const TokenInfoCol = styled.div`
display: flex;
flex-direction: column;
`;
export const UnknownMsgColumn = styled.div`
display: flex;
flex-direction: column;
text-align: left;
`;
export const TokenType = styled.div``;
export const TokenName = styled(PanelLink)`
text-decoration: none;
`;
export const TokenTicker = styled.div``;
export const TokenDesc = styled.div``;
export const ActionLink = styled.a``;
diff --git a/cashtab/src/components/Nfts/styled.ts b/cashtab/src/components/Nfts/styled.ts
index 8c89226e7..e992208a7 100644
--- a/cashtab/src/components/Nfts/styled.ts
+++ b/cashtab/src/components/Nfts/styled.ts
@@ -1,78 +1,79 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
import { token as tokenConfig } from 'config/token';
export const NftsCtn = styled.div`
color: ${props => props.theme.primaryText};
width: 100%;
h2 {
margin: 0 0 20px;
margin-top: 10px;
}
`;
export const SubHeader = styled.div`
width: 100%;
color: ${props => props.theme.primaryText};
- font-size: 24px;
- line-height: 24px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
margin-bottom: 12px;
`;
export const OfferTitle = styled.div`
margin-top: 12px;
margin-bottom: 12px;
color: ${props => props.theme.primaryText};
- font-size: 20px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
text-align: center;
font-weight: bold;
`;
export const OfferTable = styled.div`
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
width: 100%;
gap: 9px;
`;
export const OfferIcon = styled.div<{ tokenId: string; size: number }>`
display: flex;
width: 128px;
height: 128px;
background: url(${props =>
`${tokenConfig.tokenIconsUrl}/${props.size}/${props.tokenId}.png`})
center no-repeat;
background-size: 100% 100%;
transition: all ease-in-out 1s;
:hover {
background-size: 150% 150%;
}
`;
export const OfferRow = styled.div`
word-break: break-word;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3px;
align-items: center;
justify-content: center;
`;
export const OfferCol = styled.div`
width: 30%;
min-width: 128px;
display: flex;
flex-direction: column;
`;
export const NftListCtn = styled.div`
width: 100%;
display: flex;
flex-wrap: wrap;
@media (max-width: 768px) {
display: flex;
flex-direction: column;
}
`;
diff --git a/cashtab/src/components/OnBoarding/styles.js b/cashtab/src/components/OnBoarding/styles.js
index 224cea733..580d0e747 100644
--- a/cashtab/src/components/OnBoarding/styles.js
+++ b/cashtab/src/components/OnBoarding/styles.js
@@ -1,30 +1,31 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import styled from 'styled-components';
export const WelcomeCtn = styled.div`
margin-top: 20px;
padding: 0px 30px;
color: ${props => props.theme.primaryText};
h2 {
color: ${props => props.theme.primaryText};
}
`;
export const WelcomeText = styled.p`
width: 100%;
- font-size: 16px;
+ font-size: var(--text-base);
+ line-height: var(--text-base--line-height);
margin-bottom: 60px;
text-align: left;
`;
export const WelcomeLink = styled.a`
text-decoration: underline;
color: ${props => props.theme.accent};
:hover {
color: ${props => props.theme.secondaryAccent} !important;
text-decoration: underline !important;
}
`;
diff --git a/cashtab/src/components/Receive/QRCode.js b/cashtab/src/components/Receive/QRCode.js
index 9e256d969..331941386 100644
--- a/cashtab/src/components/Receive/QRCode.js
+++ b/cashtab/src/components/Receive/QRCode.js
@@ -1,210 +1,213 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import QRCodeSVG from 'qrcode.react';
import CopyToClipboard from 'components/Common/CopyToClipboard';
import appConfig from 'config/app';
export const CustomQRCode = styled(QRCodeSVG)`
cursor: pointer;
border-radius: 10px;
background: ${props => props.theme.qrBackground};
margin: 12px;
path:first-child {
fill: ${props => props.theme.qrBackground};
}
@media (max-width: 768px) {
border-radius: 18px;
}
`;
const Copied = styled.div`
- font-size: 24px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
font-family: 'Roboto Mono', monospace;
font-weight: bold;
width: 100%;
text-align: center;
background-color: ${props => props.theme.accent};
border: 1px solid;
border-color: ${props => props.theme.accent};
color: ${props => props.theme.primaryText};
position: absolute;
top: 65px;
padding: 30px 0;
`;
const PrefixLabel = styled.span`
text-align: right;
color: ${props => props.theme.accent};
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
`;
const AddressHighlightTrim = styled.span`
color: ${props => props.theme.primaryText};
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
`;
const ReceiveAddressHolder = styled.div`
width: 100%;
- font-size: 30px;
+ font-size: var(--text-3xl);
+ line-height: var(--text-3xl--line-height);
font-weight: bold;
color: ${props => props.theme.secondaryText};
text-align: center;
cursor: pointer;
margin-bottom: 10px;
padding: 0;
font-family: 'Roboto Mono', monospace;
border-radius: 5px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
input {
border: none;
width: 100%;
text-align: center;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: pointer;
color: ${props => props.theme.primaryText};
padding: 10px 0;
background: transparent;
margin-bottom: 15px;
display: none;
}
input:focus {
outline: none;
}
input::selection {
background: transparent;
color: ${props => props.theme.primaryText};
}
`;
const DisplayCopiedAddress = styled.span`
- font-size: 24px;
+ font-size: var(--text-xl);
+ line-height: var(--text-xl--line-height);
word-wrap: break-word;
`;
export const QRCode = ({
address,
size = 210,
logoSizePx = 36,
onClick = () => null,
}) => {
address = address ? address : '';
const [visible, setVisible] = useState(false);
const trimAmount = 3;
const addressSplit = address ? address.split(':') : [''];
const addressPrefix = addressSplit[0];
const prefixLength = addressPrefix.length + 1;
const handleOnClick = evt => {
setVisible(true);
setTimeout(() => {
setVisible(false);
}, 1500);
onClick(evt);
};
const getCopiedAddressBlocks = (address, sliceSize) => {
const lineCount = Math.ceil(address.length / sliceSize);
const lines = [];
for (let i = 0; i < lineCount; i += 1) {
const thisLine = (
<div style={{ display: 'block' }} key={`address_slice_${i}`}>
{address.slice(i * sliceSize, i * sliceSize + sliceSize)}
</div>
);
lines.push(thisLine);
}
return lines;
};
return (
<CopyToClipboard data={address}>
<div
style={{
display: 'inline-block',
width: '100%',
position: 'relative',
}}
>
<div style={{ position: 'relative' }} onClick={handleOnClick}>
<Copied style={{ display: visible ? null : 'none' }}>
Address Copied to Clipboard
<br />
<DisplayCopiedAddress>
{getCopiedAddressBlocks(address, 6)}
</DisplayCopiedAddress>
</Copied>
<CustomQRCode
title="Raw QR Code"
value={address || ''}
size={size}
renderAs={'svg'}
includeMargin
imageSettings={{
src: appConfig.logo,
x: null,
y: null,
height: logoSizePx,
width: logoSizePx,
excavate: true,
}}
/>
{address && (
<ReceiveAddressHolder className="notranslate">
<input
readOnly
value={address}
spellCheck="false"
type="text"
/>
<PrefixLabel>
{address.slice(0, prefixLength)}
</PrefixLabel>
<AddressHighlightTrim>
{address.slice(
prefixLength,
prefixLength + trimAmount,
)}
</AddressHighlightTrim>
{'...'}
<AddressHighlightTrim>
{address.slice(-trimAmount)}
</AddressHighlightTrim>
</ReceiveAddressHolder>
)}
</div>
</div>
</CopyToClipboard>
);
};
QRCode.propTypes = {
address: PropTypes.string,
size: PropTypes.number,
logoSizePx: PropTypes.number,
onClick: PropTypes.func,
};
diff --git a/cashtab/src/components/Send/SendXec.tsx b/cashtab/src/components/Send/SendXec.tsx
index 7b88c56c4..996b0b2a8 100644
--- a/cashtab/src/components/Send/SendXec.tsx
+++ b/cashtab/src/components/Send/SendXec.tsx
@@ -1,1618 +1,1619 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { useState, useEffect, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { WalletContext, isWalletContextLoaded } from 'wallet/context';
import Modal from 'components/Common/Modal';
import PrimaryButton from 'components/Common/Buttons';
import { toSatoshis, toXec, SlpDecimals } from 'wallet';
import { getSendTokenInputs, TokenInputInfo } from 'token-protocols';
import {
getNft,
getNftChildSendTargetOutputs,
getSlpSendTargetOutputs,
} from 'token-protocols/slpv1';
import { getAlpSendTargetOutputs } from 'token-protocols/alp';
import { sumOneToManyXec, confirmRawTx } from './helpers';
import { Event } from 'components/Common/GoogleAnalytics';
import {
isValidMultiSendUserInput,
shouldSendXecBeDisabled,
parseAddressInput,
isValidXecSendAmount,
getOpReturnRawError,
CashtabParsedAddressInfo,
isValidTokenSendOrBurnAmount,
} from 'validation';
import { ConvertAmount, Alert, AlertMsg, Info } from 'components/Common/Atoms';
import {
sendXec,
getMultisendTargetOutputs,
getMaxSendAmountSatoshis,
} from 'transactions';
import {
getCashtabMsgTargetOutput,
getAirdropTargetOutput,
getCashtabMsgByteCount,
getOpreturnParamTargetOutput,
parseOpReturnRaw,
ParsedOpReturnRaw,
} from 'opreturn';
import ApiError from 'components/Common/ApiError';
import { formatFiatBalance, formatBalance } from 'formatting';
import styled from 'styled-components';
import { opReturn as opreturnConfig } from 'config/opreturn';
import { explorer } from 'config/explorer';
import { supportedFiatCurrencies } from 'config/CashtabSettings';
import appConfig from 'config/app';
import { getUserLocale } from 'helpers';
import { hasEnoughToken, fiatToSatoshis } from 'wallet';
import { toast } from 'react-toastify';
import {
SendTokenBip21Input,
InputWithScanner,
SendXecInput,
TextArea,
} from 'components/Common/Inputs';
import Switch from 'components/Common/Switch';
import { opReturn } from 'config/opreturn';
import { Script } from 'ecash-lib';
import { CashtabCachedTokenInfo } from 'config/CashtabCache';
import TokenIcon from 'components/Etokens/TokenIcon';
import { getTokenGenesisInfo } from 'chronik';
import { InlineLoader } from 'components/Common/Spinner';
import {
AlpTokenType_Type,
SlpTokenType_Type,
TokenType,
GenesisInfo,
} from 'chronik-client';
import { SendButtonContainer } from './styled';
const OuterCtn = styled.div`
background: ${props => props.theme.primaryBackground};
padding: 20px;
border-radius: 10px;
`;
const SendXecForm = styled.div`
margin: 12px 0;
display: flex;
flex-direction: column;
gap: 12px;
`;
const SendXecRow = styled.div``;
const SwitchAndLabel = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
`;
const SwitchLabel = styled.div`
color: ${props => props.theme.primaryText};
`;
const SwitchContainer = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
color: ${props => props.theme.primaryText};
white-space: nowrap;
margin: 12px 0;
`;
const AmountPreviewCtn = styled.div`
margin: 12px;
display: flex;
flex-direction: column;
justify-content: center;
`;
const ParsedBip21InfoRow = styled.div`
display: flex;
flex-direction: column;
word-break: break-word;
`;
const ParsedBip21InfoLabel = styled.div`
color: ${props => props.theme.primaryText};
text-align: left;
width: 100%;
`;
const ParsedBip21Info = styled.div`
background-color: #fff2f0;
border-radius: 12px;
color: ${props => props.theme.accent};
padding: 12px;
text-align: left;
`;
const LocaleFormattedValue = styled.div`
color: ${props => props.theme.primaryText};
font-weight: bold;
- font-size: 1.17em;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
margin-bottom: 0;
`;
const SendToOneHolder = styled.div``;
const SendToManyHolder = styled.div``;
const SendToOneInputForm = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const InputModesHolder = styled.div<{ open: boolean }>`
min-height: 9rem;
${SendToOneHolder} {
overflow: hidden;
transition: ${props =>
props.open
? 'max-height 200ms ease-in, opacity 200ms ease-out'
: 'max-height 200ms cubic-bezier(0, 1, 0, 1), opacity 200ms ease-in'};
max-height: ${props => (props.open ? '0rem' : '12rem')};
opacity: ${props => (props.open ? 0 : 1)};
}
${SendToManyHolder} {
overflow: hidden;
transition: ${props =>
props.open
? 'max-height 200ms ease-in, transform 200ms ease-out, opacity 200ms ease-in'
: 'max-height 200ms cubic-bezier(0, 1, 0, 1), transform 200ms ease-out'};
max-height: ${props => (props.open ? '12rem' : '0rem')};
transform: ${props =>
props.open ? 'translateY(0%)' : 'translateY(100%)'};
opacity: ${props => (props.open ? 1 : 0)};
}
`;
const ParsedTokenSend = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background-color: #fff2f0;
border-radius: 12px;
color: ${props => props.theme.accent};
padding: 12px;
text-align: left;
`;
const SendTokenBip21FormRow = styled.div`
width: 100%;
display: flex;
justify-content: space-between;
gap: 12px;
margin: 3px;
`;
interface SendTokenBip21Props {
decimalizedTokenQty: string;
tokenError: false | string;
}
const SendTokenBip21: React.FC<SendTokenBip21Props> = ({
decimalizedTokenQty,
tokenError,
}) => {
return (
<SendTokenBip21FormRow>
<SendTokenBip21Input
name="amount"
placeholder="Bip21-entered token amount"
value={decimalizedTokenQty}
error={tokenError}
/>
</SendTokenBip21FormRow>
);
};
interface CashtabTxInfo {
address?: string;
bip21?: string;
value?: string;
parseAllAsBip21?: boolean;
}
const SendXec: React.FC = () => {
const ContextValue = useContext(WalletContext);
if (!isWalletContextLoaded(ContextValue)) {
// Confirm we have all context required to load the page
return null;
}
const location = useLocation();
const {
chaintipBlockheight,
fiatPrice,
apiError,
cashtabState,
updateCashtabState,
chronik,
ecc,
} = ContextValue;
const { settings, wallets, cashtabCache } = cashtabState;
const wallet = wallets[0];
const { balanceSats, tokens } = wallet.state;
const [isOneToManyXECSend, setIsOneToManyXECSend] =
useState<boolean>(false);
const [sendWithCashtabMsg, setSendWithCashtabMsg] =
useState<boolean>(false);
const [sendWithOpReturnRaw, setSendWithOpReturnRaw] =
useState<boolean>(false);
const [opReturnRawError, setOpReturnRawError] = useState<false | string>(
false,
);
const [parsedOpReturnRaw, setParsedOpReturnRaw] =
useState<ParsedOpReturnRaw>({
protocol: '',
data: '',
});
const [isSending, setIsSending] = useState<boolean>(false);
interface SendXecFormData {
amount: string;
address: string;
multiAddressInput: string;
airdropTokenId: string;
cashtabMsg: string;
opReturnRaw: string;
}
const emptyFormData: SendXecFormData = {
amount: '',
address: '',
multiAddressInput: '',
airdropTokenId: '',
cashtabMsg: '',
opReturnRaw: '',
};
const [formData, setFormData] = useState<SendXecFormData>(emptyFormData);
const [sendAddressError, setSendAddressError] = useState<false | string>(
false,
);
const [multiSendAddressError, setMultiSendAddressError] = useState<
false | string
>(false);
const [sendAmountError, setSendAmountError] = useState<string | false>(
false,
);
const [cashtabMsgError, setCashtabMsgError] = useState<string | false>(
false,
);
const [selectedCurrency, setSelectedCurrency] = useState<string>(
appConfig.ticker,
);
const [parsedAddressInput, setParsedAddressInput] =
useState<CashtabParsedAddressInfo>(parseAddressInput('', 0));
// Support cashtab button from web pages
const [txInfoFromUrl, setTxInfoFromUrl] = useState<false | CashtabTxInfo>(
false,
);
// Show a confirmation modal on transactions created by populating form from web page button
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [showConfirmSendModal, setShowConfirmSendModal] =
useState<boolean>(false);
const [tokenIdQueryError, setTokenIdQueryError] = useState<boolean>(false);
// Airdrop transactions embed the additional tokenId (32 bytes), along with prefix (4 bytes) and two pushdata (2 bytes)
// hence setting airdrop tx message limit to 38 bytes less than opreturnConfig.cashtabMsgByteLimit
const pushDataByteCount = 1;
const prefixByteCount = 4;
const tokenIdByteCount = 32;
const localAirdropTxAddedBytes =
pushDataByteCount +
tokenIdByteCount +
pushDataByteCount +
prefixByteCount; // 38
const [airdropFlag, setAirdropFlag] = useState<boolean>(false);
// Typeguard for bip21 multiple outputs parsedAddressInput
const isBip21MultipleOutputsSafe = (
parsedAddressInput: CashtabParsedAddressInfo,
): parsedAddressInput is {
address: {
value: null | string;
error: false | string;
};
parsedAdditionalXecOutputs: {
value: [string, string][];
error: false | string;
};
amount: { value: string; error: false | string };
} => {
return (
typeof parsedAddressInput !== 'undefined' &&
typeof parsedAddressInput.parsedAdditionalXecOutputs !==
'undefined' &&
typeof parsedAddressInput.parsedAdditionalXecOutputs.value !==
'undefined' &&
parsedAddressInput.parsedAdditionalXecOutputs.value !== null &&
parsedAddressInput.parsedAdditionalXecOutputs.error === false &&
typeof parsedAddressInput.amount !== 'undefined' &&
typeof parsedAddressInput.amount.value !== 'undefined' &&
parsedAddressInput.amount.value !== null
);
};
// Typeguard for a valid bip21 token send tx
const isBip21TokenSend = (
parsedAddressInput: CashtabParsedAddressInfo,
): parsedAddressInput is {
address: {
value: string;
error: false;
};
token_id: {
value: string;
error: false | string;
};
token_decimalized_qty: { value: string; error: false | string };
} => {
return (
typeof parsedAddressInput !== 'undefined' &&
typeof parsedAddressInput.address.value === 'string' &&
parsedAddressInput.address.error === false &&
typeof parsedAddressInput.token_id !== 'undefined' &&
typeof parsedAddressInput.token_id.value === 'string' &&
parsedAddressInput.token_id.error === false &&
typeof parsedAddressInput.token_decimalized_qty !== 'undefined' &&
typeof parsedAddressInput.token_decimalized_qty.value ===
'string' &&
parsedAddressInput.token_decimalized_qty.error === false
);
};
const addTokenToCashtabCache = async (tokenId: string) => {
let tokenInfo;
try {
tokenInfo = await getTokenGenesisInfo(chronik, tokenId);
} catch (err) {
console.error(`Error getting token details for ${tokenId}`, err);
return setTokenIdQueryError(true);
}
// If we successfully get tokenInfo, update cashtabCache
cashtabCache.tokens.set(tokenId, tokenInfo);
updateCashtabState('cashtabCache', cashtabCache);
// Unset in case user is checking a new token that does exist this time
setTokenIdQueryError(false);
};
// Shorthand this calc as well as it is used in multiple spots
// Note that we must "double cover" some conditions bc typescript doesn't get it
const bip21MultipleOutputsFormattedTotalSendXec =
isBip21MultipleOutputsSafe(parsedAddressInput)
? parsedAddressInput.parsedAdditionalXecOutputs.value.reduce(
(accumulator, addressAmountArray) =>
accumulator + parseFloat(addressAmountArray[1]),
parseFloat(parsedAddressInput.amount.value),
)
: 0;
const userLocale = getUserLocale(navigator);
const clearInputForms = () => {
setFormData(emptyFormData);
setParsedAddressInput(parseAddressInput('', 0));
// Reset to XEC
// Note, this ensures we never are in fiat send mode for multi-send
setSelectedCurrency(appConfig.ticker);
};
const checkForConfirmationBeforeSendXec = () => {
if (settings.sendModal) {
setIsModalVisible(settings.sendModal);
} else {
// if the user does not have the send confirmation enabled in settings then send directly
send();
}
};
const handleOk = () => {
setIsModalVisible(false);
send();
};
const handleCancel = () => {
setIsModalVisible(false);
};
useEffect(() => {
// Manually parse for txInfo object on page load when Send.js is loaded with a query string
// if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field
if (location && location.state && location.state.replyAddress) {
// Populate a dust tx to the reply address
setFormData({
...formData,
address: location.state.replyAddress,
amount: `${toXec(appConfig.dustSats)}`,
});
// Turn on the Cashtab Msg switch
setSendWithCashtabMsg(true);
}
// if this was routed from the Contact List
if (location && location.state && location.state.contactSend) {
// explicitly trigger the address validation upon navigation from contact list
handleAddressChange({
target: {
name: 'address',
value: location.state.contactSend,
},
} as React.ChangeEvent<HTMLInputElement>);
}
// if this was routed from the Airdrop screen's Airdrop Calculator then
// switch to multiple recipient mode and prepopulate the recipients field
if (
location &&
location.state &&
location.state.airdropRecipients &&
location.state.airdropTokenId
) {
setIsOneToManyXECSend(true);
setFormData({
...formData,
multiAddressInput: location.state.airdropRecipients,
airdropTokenId: location.state.airdropTokenId,
cashtabMsg: '',
});
// validate the airdrop outputs from the calculator
handleMultiAddressChange({
target: {
value: location.state.airdropRecipients,
},
} as React.ChangeEvent<HTMLTextAreaElement>);
setAirdropFlag(true);
}
// Do not set txInfo in state if query strings are not present
if (
!window.location ||
!window.location.hash ||
window.location.hash === '#/send'
) {
return;
}
// Get everything after the first ? mark
const hashRoute = window.location.hash;
// The "+1" is because we want to also omit the first question mark
// So we need to slice at 1 character past it
const txInfoStr = hashRoute.slice(hashRoute.indexOf('?') + 1);
const txInfo: CashtabTxInfo = {};
// If bip21 is the first param, parse the whole string as a bip21 param string
const parseAllAsBip21 = txInfoStr.startsWith('bip21');
if (parseAllAsBip21) {
// Cashtab requires param string to start with bip21 if this is requesting bip21 validation
txInfo.bip21 = txInfoStr.slice('bip21='.length);
} else {
// Parse for legacy amount and value params
const legacyParams = new URLSearchParams(txInfoStr);
// Check for duplicated params
const duplicatedParams =
new Set(legacyParams.keys()).size !==
Array.from(legacyParams.keys()).length;
if (!duplicatedParams) {
const supportedLegacyParams = ['address', 'value'];
// Iterate over
for (const paramKeyValue of legacyParams) {
const paramKey = paramKeyValue[0];
if (!supportedLegacyParams.includes(paramKey)) {
// ignore unsupported params
continue;
}
txInfo[
paramKey as keyof Omit<CashtabTxInfo, 'parseAllAsBip21'>
] = paramKeyValue[1];
}
}
}
// Only set txInfoFromUrl if you have valid legacy params or bip21
const validUrlParams =
(parseAllAsBip21 && 'bip21' in txInfo) ||
// Good if we have both address and value
('address' in txInfo && 'value' in txInfo) ||
// Good if we have address and no value
('address' in txInfo && !('value' in txInfo));
// If we 'value' key with no address, no good
// Note: because only the address and value keys are handled below,
// it's not an issue if we get all kinds of other garbage params
if (validUrlParams) {
// This is a tx request from the URL
// Save this flag in state var so it can be parsed in useEffect
txInfo.parseAllAsBip21 = parseAllAsBip21;
setTxInfoFromUrl(txInfo);
}
}, []);
useEffect(() => {
if (txInfoFromUrl === false) {
return;
}
if (txInfoFromUrl.parseAllAsBip21) {
handleAddressChange({
target: {
name: 'address',
value: txInfoFromUrl.bip21,
},
} as React.ChangeEvent<HTMLInputElement>);
} else {
// Enter address into input field and trigger handleAddressChange for validation
handleAddressChange({
target: {
name: 'address',
value: txInfoFromUrl.address,
},
} as React.ChangeEvent<HTMLInputElement>);
if (
typeof txInfoFromUrl.value !== 'undefined' &&
!Number.isNaN(parseFloat(txInfoFromUrl.value))
) {
// Only update the amount field if txInfo.value is a good input
// Sometimes we want this field to be adjusted by the user, e.g. a donation amount
// Do not populate the field if the value param is not parseable as a number
// the strings 'undefined' and 'null', which PayButton passes to signify 'no amount', fail this test
// TODO deprecate this support once PayButton and cashtab-components do not require it
handleAmountChange({
target: {
name: 'amount',
value: txInfoFromUrl.value,
},
} as React.ChangeEvent<HTMLInputElement>);
}
}
// We re-run this when balanceSats changes because validation of send amounts depends on balanceSats
}, [txInfoFromUrl, balanceSats]);
interface XecSendError {
error?: string;
message?: string;
}
function handleSendXecError(errorObj: XecSendError) {
let message;
if (
errorObj.error &&
errorObj.error.includes(
'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)',
)
) {
message = `The ${appConfig.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`;
} else {
message =
errorObj.message || errorObj.error || JSON.stringify(errorObj);
}
toast.error(`${message}`);
}
const sendToken = async () => {
if (!isBip21TokenSend(parsedAddressInput)) {
// Should never happen
toast.error(`Error parsing token info for token send`);
return;
}
const address = parsedAddressInput.address.value;
const tokenId = parsedAddressInput.token_id.value;
const decimalizedTokenQty =
parsedAddressInput.token_decimalized_qty.value;
const cachedTokenInfo = cashtabCache.tokens.get(tokenId);
if (typeof cachedTokenInfo === 'undefined') {
// Should never happen
toast.error(`Error: token info not in cache`);
return;
}
const { genesisInfo, tokenType } = cachedTokenInfo;
const { decimals } = genesisInfo;
const { type } = tokenType;
// GA event
Event('SendXec', 'Bip21 Token Send', tokenId);
try {
// Get input utxos for slpv1 or ALP send tx
// NFT send utxos are handled differently
const tokenInputInfo =
type === 'SLP_TOKEN_TYPE_NFT1_CHILD'
? undefined
: getSendTokenInputs(
wallet.state.slpUtxos,
tokenId as string,
decimalizedTokenQty,
decimals as SlpDecimals,
);
// Get targetOutputs for an slpv1 send tx
const tokenSendTargetOutputs =
type === 'SLP_TOKEN_TYPE_NFT1_CHILD'
? getNftChildSendTargetOutputs(tokenId as string, address)
: type === 'ALP_TOKEN_TYPE_STANDARD'
? getAlpSendTargetOutputs(
tokenInputInfo as TokenInputInfo,
address,
)
: getSlpSendTargetOutputs(
tokenInputInfo as TokenInputInfo,
address,
);
// Build and broadcast the tx
const { response } = await sendXec(
chronik,
ecc,
wallet,
tokenSendTargetOutputs,
settings.minFeeSends &&
(hasEnoughToken(
tokens,
appConfig.vipTokens.grumpy.tokenId,
appConfig.vipTokens.grumpy.vipBalance,
) ||
hasEnoughToken(
tokens,
appConfig.vipTokens.cachet.tokenId,
appConfig.vipTokens.cachet.vipBalance,
))
? appConfig.minFee
: appConfig.defaultFee,
chaintipBlockheight,
type === 'SLP_TOKEN_TYPE_NFT1_CHILD'
? getNft(tokenId as string, wallet.state.slpUtxos)
: (tokenInputInfo as TokenInputInfo).tokenInputs,
);
confirmRawTx(
<a
href={`${explorer.blockExplorerUrl}/tx/${response.txid}`}
target="_blank"
rel="noopener noreferrer"
>
{type === 'SLP_TOKEN_TYPE_NFT1_CHILD'
? 'NFT sent'
: 'eToken sent'}
</a>,
);
clearInputForms();
// Hide the confirmation modal if it was showing
setShowConfirmSendModal(false);
} catch (e) {
console.error(
`Error sending ${
type === 'SLP_TOKEN_TYPE_NFT1_CHILD' ? 'NFT' : 'token'
}`,
e,
);
toast.error(`${e}`);
}
};
const checkForConfirmationBeforeBip21TokenSend = () => {
if (settings.sendModal) {
setShowConfirmSendModal(true);
} else {
// if the user does not have the send confirmation enabled in settings then send directly
sendToken();
}
};
async function send() {
setIsSending(true);
setFormData({
...formData,
});
// Initialize targetOutputs for this tx
let targetOutputs = [];
// If you have an OP_RETURN output, add it at index 0
// Aesthetic choice, easier to see when checking on block explorer
if (airdropFlag) {
// Airdrop txs require special OP_RETURN handling
targetOutputs.push(
getAirdropTargetOutput(
formData.airdropTokenId,
formData.cashtabMsg,
),
);
} else if (sendWithCashtabMsg && formData.cashtabMsg !== '') {
// Send this tx with a Cashtab msg if the user has the switch enabled and the input field is not empty
targetOutputs.push(getCashtabMsgTargetOutput(formData.cashtabMsg));
} else if (formData.opReturnRaw !== '' && opReturnRawError === false) {
targetOutputs.push(
getOpreturnParamTargetOutput(formData.opReturnRaw),
);
}
if (isOneToManyXECSend) {
// Handle XEC send to multiple addresses
targetOutputs = targetOutputs.concat(
getMultisendTargetOutputs(formData.multiAddressInput),
);
Event('Send.js', 'SendToMany', selectedCurrency);
} else {
// Handle XEC send to one address
const cleanAddress = formData.address.split('?')[0];
const satoshisToSend =
selectedCurrency === 'XEC'
? toSatoshis(parseFloat(formData.amount))
: fiatToSatoshis(formData.amount, fiatPrice as number);
targetOutputs.push({
script: Script.fromAddress(cleanAddress),
sats: BigInt(satoshisToSend),
});
if (isBip21MultipleOutputsSafe(parsedAddressInput)) {
parsedAddressInput.parsedAdditionalXecOutputs.value.forEach(
([addr, amount]) => {
targetOutputs.push({
script: Script.fromAddress(addr),
sats: BigInt(toSatoshis(parseFloat(amount))),
});
},
);
Event('Send.js', 'SendToMany', selectedCurrency);
} else {
Event('Send.js', 'Send', selectedCurrency);
}
}
// Send and notify
try {
const txObj = await sendXec(
chronik,
ecc,
wallet,
targetOutputs,
settings.minFeeSends &&
(hasEnoughToken(
tokens,
appConfig.vipTokens.grumpy.tokenId,
appConfig.vipTokens.grumpy.vipBalance,
) ||
hasEnoughToken(
tokens,
appConfig.vipTokens.cachet.tokenId,
appConfig.vipTokens.cachet.vipBalance,
))
? appConfig.minFee
: appConfig.defaultFee,
chaintipBlockheight,
);
confirmRawTx(
<a
href={`${explorer.blockExplorerUrl}/tx/${txObj.response.txid}`}
target="_blank"
rel="noopener noreferrer"
>
eCash sent
</a>,
);
clearInputForms();
setAirdropFlag(false);
setIsSending(false);
if (txInfoFromUrl) {
// Close window after successful tx
window.close();
}
} catch (err) {
handleSendXecError(err as XecSendError);
setIsSending(false);
}
}
const handleAddressChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
if (tokenIdQueryError) {
// Clear tokenIdQueryError if we have one
setTokenIdQueryError(false);
}
const { value, name } = e.target;
const parsedAddressInput = parseAddressInput(
value,
balanceSats,
userLocale,
);
// Set in state as various param outputs determine app rendering
// For example, a valid amount param should disable user amount input
setParsedAddressInput(parsedAddressInput);
let renderedSendToError = parsedAddressInput.address.error;
if (
typeof parsedAddressInput.queryString !== 'undefined' &&
typeof parsedAddressInput.queryString.error === 'string'
) {
// If you have a bad queryString, this should be the rendered error
renderedSendToError = parsedAddressInput.queryString.error;
}
// Handle errors in op_return_raw as an address error if no other error is set
if (
renderedSendToError === false &&
typeof parsedAddressInput.op_return_raw !== 'undefined' &&
typeof parsedAddressInput.op_return_raw.error === 'string'
) {
renderedSendToError = parsedAddressInput.op_return_raw.error;
}
// Handle errors in secondary addr&amount params
if (
renderedSendToError === false &&
typeof parsedAddressInput.parsedAdditionalXecOutputs !==
'undefined' &&
typeof parsedAddressInput.parsedAdditionalXecOutputs.error ===
'string'
) {
renderedSendToError =
parsedAddressInput.parsedAdditionalXecOutputs.error;
}
// Handle bip21 token errors
if (
renderedSendToError === false &&
typeof parsedAddressInput.token_decimalized_qty !== 'undefined' &&
parsedAddressInput.token_decimalized_qty.error !== false
) {
// If we have an invalid token_decimalized_qty but a good bip21 query string
renderedSendToError =
parsedAddressInput.token_decimalized_qty.error;
}
if (
renderedSendToError === false &&
typeof parsedAddressInput.token_id !== 'undefined'
) {
if (parsedAddressInput.token_id.error !== false) {
// If we have an invalid token id but a good bip21 query string
renderedSendToError = parsedAddressInput.token_id.error;
} else {
// We have valid token send bip21 and no error
if (typeof parsedAddressInput.token_id.value === 'string') {
// Should always be true if we have error false here
// get and cache token info if we have a valid token ID and no renderedSendToError
addTokenToCashtabCache(parsedAddressInput.token_id.value);
}
}
}
setSendAddressError(renderedSendToError);
if (typeof parsedAddressInput.amount !== 'undefined') {
// Set currency to non-fiat
setSelectedCurrency(appConfig.ticker);
// Use this object to mimic user input and get validation for the value
const amountObj = {
target: {
name: 'amount',
value: parsedAddressInput.amount.value,
},
};
handleAmountChange(
amountObj as React.ChangeEvent<HTMLInputElement>,
);
}
// Set op_return_raw if it's in the query string
if (typeof parsedAddressInput.op_return_raw !== 'undefined') {
// In general, we want to show the op_return_raw value even if there is an error,
// so the user can see what it is
// However in some cases, like duplicate op_return_raw, we do not even have a value to show
// So, only render if we have a renderable value
if (typeof parsedAddressInput.op_return_raw.value === 'string') {
// Turn on sendWithOpReturnRaw
setSendWithOpReturnRaw(true);
// Update the op_return_raw field and trigger its validation
handleOpReturnRawInput({
target: {
name: 'opReturnRaw',
value: parsedAddressInput.op_return_raw.value,
},
} as React.ChangeEvent<HTMLTextAreaElement>);
}
}
// Set address field to user input
setFormData(p => ({
...p,
[name]: value,
}));
};
const handleMultiAddressChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const { value, name } = e.target;
const errorOrIsValid = isValidMultiSendUserInput(
value,
balanceSats,
userLocale,
);
// If you get an error msg, set it. If validation is good, clear error msg.
setMultiSendAddressError(
typeof errorOrIsValid === 'string' ? errorOrIsValid : false,
);
// Set address field to user input
setFormData(p => ({
...p,
[name]: value,
}));
};
const handleSelectedCurrencyChange = (
e: React.ChangeEvent<HTMLSelectElement>,
) => {
setSelectedCurrency(e.target.value);
// Clear input field to prevent accidentally sending 1 XEC instead of 1 USD
setFormData(p => ({
...p,
amount: '',
}));
};
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value, name } = e.target;
// Validate user input send amount
const isValidAmountOrErrorMsg = isValidXecSendAmount(
value,
balanceSats,
userLocale,
selectedCurrency,
fiatPrice as number,
);
setSendAmountError(
isValidAmountOrErrorMsg !== true ? isValidAmountOrErrorMsg : false,
);
setFormData(p => ({
...p,
[name]: value,
}));
};
const handleOpReturnRawInput = (
e: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
// Validate input
const error = getOpReturnRawError(value);
setOpReturnRawError(error);
// Update formdata
setFormData(p => ({
...p,
[name]: value,
}));
// Update parsedOpReturn if we have no opReturnRawError
if (error === false) {
// Need to gate this for no error as parseOpReturnRaw expects a validated op_return_raw
setParsedOpReturnRaw(parseOpReturnRaw(value));
}
};
const handleCashtabMsgChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
let cashtabMsgError: false | string = false;
const msgByteSize = getCashtabMsgByteCount(value);
const maxSize =
location && location.state && location.state.airdropTokenId
? opreturnConfig.cashtabMsgByteLimit - localAirdropTxAddedBytes
: opreturnConfig.cashtabMsgByteLimit;
if (msgByteSize > maxSize) {
cashtabMsgError = `Message can not exceed ${maxSize} bytes`;
}
setCashtabMsgError(cashtabMsgError);
setFormData(p => ({
...p,
[name]: value,
}));
};
const onMax = () => {
// Clear amt error
setSendAmountError(false);
// Set currency to XEC
setSelectedCurrency(appConfig.ticker);
// Account for CashtabMsg if it is set
const intendedTargetOutputs =
sendWithCashtabMsg && formData.cashtabMsg !== ''
? [getCashtabMsgTargetOutput(formData.cashtabMsg)]
: [];
// Build a tx sending all non-token utxos
// Determine the amount being sent (outputs less fee)
let maxSendSatoshis;
try {
// An error will be thrown if the wallet has insufficient funds to send more than dust
maxSendSatoshis = getMaxSendAmountSatoshis(
wallet,
intendedTargetOutputs,
chaintipBlockheight,
settings.minFeeSends &&
(hasEnoughToken(
tokens,
appConfig.vipTokens.grumpy.tokenId,
appConfig.vipTokens.grumpy.vipBalance,
) ||
hasEnoughToken(
tokens,
appConfig.vipTokens.cachet.tokenId,
appConfig.vipTokens.cachet.vipBalance,
))
? appConfig.minFee
: appConfig.defaultFee,
);
} catch {
// Set to zero. In this case, 0 is the max amount we can send, and we know
// this will trigger the expected dust validation error
maxSendSatoshis = 0;
}
// Convert to XEC to set in form
const maxSendXec = toXec(maxSendSatoshis);
// Update value in the send field
// Note, if we are updating it to 0, we will get a 'dust' error
handleAmountChange({
target: {
name: 'amount',
value: maxSendXec,
},
} as unknown as React.ChangeEvent<HTMLInputElement>);
};
// Display price in USD below input field for send amount, if it can be calculated
let fiatPriceString = '';
let multiSendTotal =
typeof formData.multiAddressInput === 'string'
? sumOneToManyXec(formData.multiAddressInput.split('\n'))
: 0;
if (isNaN(multiSendTotal)) {
multiSendTotal = 0;
}
if (fiatPrice !== null && !isNaN(parseFloat(formData.amount))) {
if (selectedCurrency === appConfig.ticker) {
// insert symbol and currency before/after the locale formatted fiat balance
fiatPriceString = isOneToManyXECSend
? `${
settings
? `${
supportedFiatCurrencies[settings.fiatCurrency]
.symbol
} `
: '$ '
} ${(fiatPrice * multiSendTotal).toLocaleString(userLocale, {
minimumFractionDigits: appConfig.cashDecimals,
maximumFractionDigits: appConfig.cashDecimals,
})} ${
settings && settings.fiatCurrency
? settings.fiatCurrency.toUpperCase()
: 'USD'
}`
: `${
settings
? `${
supportedFiatCurrencies[settings.fiatCurrency]
.symbol
} `
: '$ '
} ${
isBip21MultipleOutputsSafe(parsedAddressInput)
? `${(
fiatPrice *
bip21MultipleOutputsFormattedTotalSendXec
).toLocaleString(userLocale, {
minimumFractionDigits: appConfig.cashDecimals,
maximumFractionDigits: appConfig.cashDecimals,
})}`
: `${(
fiatPrice * parseFloat(formData.amount)
).toLocaleString(userLocale, {
minimumFractionDigits: appConfig.cashDecimals,
maximumFractionDigits: appConfig.cashDecimals,
})}`
} ${
settings && settings.fiatCurrency
? settings.fiatCurrency.toUpperCase()
: 'USD'
}`;
} else {
fiatPriceString = `${
formData.amount !== '0'
? formatFiatBalance(
toXec(fiatToSatoshis(formData.amount, fiatPrice)),
userLocale,
)
: formatFiatBalance(0, userLocale)
} ${appConfig.ticker}`;
}
}
const priceApiError = fiatPrice === null && selectedCurrency !== 'XEC';
const disableSendButton = shouldSendXecBeDisabled(
formData,
balanceSats,
apiError,
sendAmountError,
sendAddressError,
multiSendAddressError,
sendWithCashtabMsg,
cashtabMsgError,
sendWithOpReturnRaw,
opReturnRawError,
priceApiError,
isOneToManyXECSend,
);
// Send token variables
const cachedInfo: undefined | CashtabCachedTokenInfo = isBip21TokenSend(
parsedAddressInput,
)
? cashtabCache.tokens.get(parsedAddressInput.token_id.value)
: undefined;
const cachedInfoLoaded = typeof cachedInfo !== 'undefined';
let tokenType: undefined | TokenType,
protocol: undefined | 'SLP' | 'ALP',
type: undefined | AlpTokenType_Type | SlpTokenType_Type,
genesisInfo: undefined | GenesisInfo,
tokenName: undefined | string,
tokenTicker: undefined | string,
decimals: undefined | number;
let nameAndTicker = '';
let tokenError: false | string = false;
const addressPreview = isBip21TokenSend(parsedAddressInput)
? `${parsedAddressInput.address.value.slice(
0,
'ecash:'.length + 3,
)}...${parsedAddressInput.address.value.slice(-3)}`
: '';
const decimalizedTokenQty = isBip21TokenSend(parsedAddressInput)
? parsedAddressInput.token_decimalized_qty.value
: '';
if (cachedInfoLoaded && isBip21TokenSend(parsedAddressInput)) {
({ tokenType, genesisInfo } = cachedInfo);
({ protocol, type } = tokenType);
({ tokenName, tokenTicker, decimals } = genesisInfo);
nameAndTicker = `${tokenName}${
tokenTicker !== '' ? ` (${tokenTicker})` : ''
}`;
// Cashtab does not yet support sending all types of tokens
const cashtabSupportedSendTypes = [
'ALP_TOKEN_TYPE_STANDARD',
'SLP_TOKEN_TYPE_FUNGIBLE',
'SLP_TOKEN_TYPE_NFT1_CHILD',
];
const tokenBalance = tokens.get(parsedAddressInput.token_id.value);
if (!cashtabSupportedSendTypes.includes(type)) {
tokenError = `Cashtab does not support sending this type of token (${type})`;
} else if (typeof tokenBalance === 'undefined') {
// User has none of this token
tokenError = 'You do not hold any of this token.';
} else {
const isValidAmountOrErrorMsg = isValidTokenSendOrBurnAmount(
decimalizedTokenQty,
tokenBalance,
decimals as SlpDecimals,
protocol,
);
tokenError =
isValidAmountOrErrorMsg === true
? false
: isValidAmountOrErrorMsg;
}
}
return (
<OuterCtn>
{showConfirmSendModal && (
<Modal
title={`Send ${decimalizedTokenQty} ${nameAndTicker} to ${addressPreview}?`}
handleOk={sendToken}
handleCancel={() => setShowConfirmSendModal(false)}
showCancelButton
/>
)}
{isModalVisible && (
<Modal
title="Confirm Send"
description={
isOneToManyXECSend
? `Send
${multiSendTotal.toLocaleString(userLocale, {
maximumFractionDigits: 2,
})}
XEC to multiple recipients?`
: `Send ${formData.amount}${' '}
${selectedCurrency} to ${parsedAddressInput.address.value}`
}
handleOk={handleOk}
handleCancel={handleCancel}
showCancelButton
/>
)}
<SwitchContainer>
<Switch
name="Toggle Multisend"
on="Send to many"
off="Send to one"
width={150}
right={115}
checked={isOneToManyXECSend}
disabled={
txInfoFromUrl !== false ||
'queryString' in parsedAddressInput
}
handleToggle={() =>
setIsOneToManyXECSend(!isOneToManyXECSend)
}
/>
</SwitchContainer>
<InputModesHolder open={isOneToManyXECSend}>
<SendToOneHolder>
<SendToOneInputForm>
<InputWithScanner
placeholder={'Address'}
name="address"
value={formData.address}
disabled={txInfoFromUrl !== false}
handleInput={handleAddressChange}
error={sendAddressError}
/>
{isBip21MultipleOutputsSafe(parsedAddressInput) ? (
<Info>
<b>
BIP21: Sending{' '}
{bip21MultipleOutputsFormattedTotalSendXec.toLocaleString(
userLocale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2,
},
)}{' '}
XEC to{' '}
{parsedAddressInput
.parsedAdditionalXecOutputs.value
.length + 1}{' '}
outputs
</b>
</Info>
) : isBip21TokenSend(parsedAddressInput) &&
tokenIdQueryError === false ? (
<>
{typeof cashtabCache.tokens.get(
parsedAddressInput.token_id.value,
) !== 'undefined' ? (
<SendTokenBip21
decimalizedTokenQty={
parsedAddressInput
.token_decimalized_qty.value
}
tokenError={tokenError}
/>
) : (
<InlineLoader />
)}
</>
) : (
<SendXecInput
name="amount"
value={formData.amount}
selectValue={selectedCurrency}
selectDisabled={
'amount' in parsedAddressInput ||
txInfoFromUrl !== false
}
inputDisabled={
priceApiError ||
(txInfoFromUrl !== false &&
'value' in txInfoFromUrl &&
txInfoFromUrl.value !== 'null' &&
txInfoFromUrl.value !== 'undefined') ||
'amount' in parsedAddressInput
}
fiatCode={settings.fiatCurrency.toUpperCase()}
error={sendAmountError}
handleInput={handleAmountChange}
handleSelect={handleSelectedCurrencyChange}
handleOnMax={onMax}
/>
)}
</SendToOneInputForm>
</SendToOneHolder>
{isBip21TokenSend(parsedAddressInput) && tokenIdQueryError && (
<Alert>
Error querying token info for{' '}
{parsedAddressInput.token_id.value}
</Alert>
)}
{priceApiError && (
<AlertMsg>
Error fetching fiat price. Setting send by{' '}
{supportedFiatCurrencies[
settings.fiatCurrency
].slug.toUpperCase()}{' '}
disabled
</AlertMsg>
)}
<SendToManyHolder>
<TextArea
placeholder={`One address & amount per line, separated by comma \ne.g. \necash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8,500 \necash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqq0knykfed,700`}
name="multiAddressInput"
handleInput={e => handleMultiAddressChange(e)}
value={formData.multiAddressInput}
error={multiSendAddressError}
/>
</SendToManyHolder>
</InputModesHolder>
<SendXecForm>
<SendXecRow>
<SwitchAndLabel>
<Switch
name="Toggle Cashtab Msg"
on="✉️"
off="✉️"
checked={sendWithCashtabMsg}
disabled={
txInfoFromUrl !== false ||
'queryString' in parsedAddressInput
}
handleToggle={() => {
// If we are sending a Cashtab msg, toggle off op_return_raw
if (
!sendWithCashtabMsg &&
sendWithOpReturnRaw
) {
setSendWithOpReturnRaw(false);
}
setSendWithCashtabMsg(!sendWithCashtabMsg);
}}
/>
<SwitchLabel>Cashtab Msg</SwitchLabel>
</SwitchAndLabel>
</SendXecRow>
{sendWithCashtabMsg && (
<SendXecRow>
<TextArea
name="cashtabMsg"
height={62}
placeholder={`Include a public Cashtab msg with this tx ${
location &&
location.state &&
location.state.airdropTokenId
? `(max ${
opreturnConfig.cashtabMsgByteLimit -
localAirdropTxAddedBytes
} bytes)`
: `(max ${opreturnConfig.cashtabMsgByteLimit} bytes)`
}`}
value={formData.cashtabMsg}
error={cashtabMsgError}
showCount
customCount={getCashtabMsgByteCount(
formData.cashtabMsg,
)}
max={
location &&
location.state &&
location.state.airdropTokenId
? opreturnConfig.cashtabMsgByteLimit -
localAirdropTxAddedBytes
: opreturnConfig.cashtabMsgByteLimit
}
handleInput={e => handleCashtabMsgChange(e)}
/>
</SendXecRow>
)}
<SendXecRow>
<SwitchAndLabel>
<Switch
name="Toggle op_return_raw"
checked={sendWithOpReturnRaw}
disabled={
txInfoFromUrl !== false ||
'queryString' in parsedAddressInput
}
handleToggle={() => {
// If we are sending with op_return_raw, toggle off CashtabMsg
if (
!sendWithOpReturnRaw &&
sendWithCashtabMsg
) {
setSendWithCashtabMsg(false);
}
setSendWithOpReturnRaw(!sendWithOpReturnRaw);
}}
/>
<SwitchLabel>op_return_raw</SwitchLabel>
</SwitchAndLabel>
</SendXecRow>
{isBip21TokenSend(parsedAddressInput) &&
tokenIdQueryError === false && (
<ParsedTokenSend>
<TokenIcon
size={64}
tokenId={parsedAddressInput.token_id.value}
/>
Sending {decimalizedTokenQty} {nameAndTicker} to{' '}
{addressPreview}
</ParsedTokenSend>
)}
{sendWithOpReturnRaw && (
<>
<SendXecRow>
<TextArea
name="opReturnRaw"
height={62}
placeholder={`(Advanced) Enter raw hex to be included with this transaction's OP_RETURN`}
value={formData.opReturnRaw}
error={opReturnRawError}
disabled={
txInfoFromUrl !== false ||
'queryString' in parsedAddressInput
}
showCount
max={2 * opReturn.opreturnParamByteLimit}
handleInput={handleOpReturnRawInput}
/>
</SendXecRow>
{opReturnRawError === false &&
formData.opReturnRaw !== '' && (
<SendXecRow>
<ParsedBip21InfoRow>
<ParsedBip21InfoLabel>
Parsed op_return_raw
</ParsedBip21InfoLabel>
<ParsedBip21Info>
<b>{parsedOpReturnRaw.protocol}</b>
<br />
{parsedOpReturnRaw.data}
</ParsedBip21Info>
</ParsedBip21InfoRow>
</SendXecRow>
)}
</>
)}
{isBip21MultipleOutputsSafe(parsedAddressInput) && (
<SendXecRow>
<ParsedBip21InfoRow>
<ParsedBip21InfoLabel>
Parsed BIP21 outputs
</ParsedBip21InfoLabel>
<ParsedBip21Info>
<ol>
<li
title={
parsedAddressInput.address
.value as string
}
>{`${(
parsedAddressInput.address
.value as string
).slice(6, 12)}...${(
parsedAddressInput.address
.value as string
).slice(-6)}, ${parseFloat(
parsedAddressInput.amount.value,
).toLocaleString(userLocale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} XEC`}</li>
{Array.from(
parsedAddressInput
.parsedAdditionalXecOutputs.value,
).map(([addr, amount], index) => {
return (
<li
key={index}
title={addr}
>{`${addr.slice(
6,
12,
)}...${addr.slice(
-6,
)}, ${parseFloat(
amount,
).toLocaleString(userLocale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} XEC`}</li>
);
})}
</ol>
</ParsedBip21Info>
</ParsedBip21InfoRow>
</SendXecRow>
)}
</SendXecForm>
<AmountPreviewCtn>
{!priceApiError && (
<>
{isOneToManyXECSend ? (
<LocaleFormattedValue>
{formatBalance(
multiSendTotal.toString(),
userLocale,
) +
' ' +
selectedCurrency}
</LocaleFormattedValue>
) : isBip21MultipleOutputsSafe(parsedAddressInput) ? (
<LocaleFormattedValue>
{bip21MultipleOutputsFormattedTotalSendXec.toLocaleString(
userLocale,
{
maximumFractionDigits: 2,
minimumFractionDigits: 2,
},
)}{' '}
XEC
</LocaleFormattedValue>
) : (
<LocaleFormattedValue>
{!isNaN(parseFloat(formData.amount))
? formatBalance(
formData.amount,
userLocale,
) +
' ' +
selectedCurrency
: ''}
</LocaleFormattedValue>
)}
<ConvertAmount>
{fiatPriceString !== '' && '='} {fiatPriceString}
</ConvertAmount>
</>
)}
</AmountPreviewCtn>
<SendButtonContainer>
<PrimaryButton
disabled={
(!isBip21TokenSend(parsedAddressInput) &&
disableSendButton) ||
(isBip21TokenSend(parsedAddressInput) &&
tokenError !== false) ||
tokenIdQueryError
}
onClick={
isBip21TokenSend(parsedAddressInput)
? checkForConfirmationBeforeBip21TokenSend
: checkForConfirmationBeforeSendXec
}
>
{isSending ? <InlineLoader /> : 'Send'}
</PrimaryButton>
</SendButtonContainer>
{apiError && <ApiError />}
</OuterCtn>
);
};
export default SendXec;
diff --git a/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js b/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js
index b76f6a650..c1dfe018f 100644
--- a/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js
+++ b/cashtab/src/components/SignVerifyMsg/SignVerifyMsg.js
@@ -1,263 +1,264 @@
// Copyright (c) 2024 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
import React, { useState } from 'react';
import styled from 'styled-components';
import { TextArea, Input } from 'components/Common/Inputs';
import Switch from 'components/Common/Switch';
import { WalletContext } from 'wallet/context';
import CopyToClipboard from 'components/Common/CopyToClipboard';
import PrimaryButton, { SecondaryButton } from 'components/Common/Buttons';
import { isValidCashAddress } from 'ecashaddrjs';
import { toast } from 'react-toastify';
import { theme } from 'assets/styles/theme';
import appConfig from 'config/app';
import { PageHeader } from 'components/Common/Atoms';
import { ThemedSignAndVerifyMsg } from 'components/Common/CustomIcons';
import { signMsg, verifyMsg } from 'ecash-lib';
const SignVerifyForm = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
h2 {
margin-bottom: 20px;
}
`;
const Row = styled.div`
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
`;
const SignatureLabel = styled.div`
- font-size: 18px;
+ font-size: var(--text-lg);
+ line-height: var(--text-lg--line-height);
color: ${props => props.theme.primaryText};
text-align: left;
font-weight: bold;
width: 100%;
`;
const SignatureHolder = styled.code`
width: 100%;
color: ${props => props.theme.primaryText};
word-break: break-all;
`;
const SignVerifyMsg = () => {
const ContextValue = React.useContext(WalletContext);
const { cashtabState } = ContextValue;
const { wallets } = cashtabState;
const wallet = wallets.length > 0 ? wallets[0] : false;
// Cap msg length to prevent significant computation
// Note that emojis etc could have larger impact than length
// However, it is not that important, we do not need to get a bytecount for this component
const CASHTAB_MESSAGE_MAX_LENGTH = 200;
const ECASH_SIGNED_MSG_LENGTH = 88;
const emptyFormData = {
msgToSign: '',
msgToVerify: '',
addressToVerify: '',
signatureToVerify: '',
};
const emptyFormDataError = {
msgToSign: false,
msgToVerify: false,
addressToVerify: false,
signatureToVerify: false,
};
const [formData, setFormData] = useState(emptyFormData);
const [formDataError, setFormDataError] = useState(emptyFormDataError);
const [signMsgMode, setSignMsgMode] = useState(true);
const [messageSignature, setMessageSignature] = useState('');
const handleUserSignature = () => {
// We get the msgToSign from formData in state
const { msgToSign } = formData;
// Wrap signing in try...catch to handle any errors
try {
// sign with path 1899 sk
const sk = wallet.paths.get(1899).sk;
const signature = signMsg(msgToSign, sk);
setMessageSignature(signature);
toast.success('Message Signed');
} catch (err) {
toast.error(`${err}`);
throw err;
}
};
/**
* Update formData with user input
* @param {Event} e js input event
* e.target.value will be input value
* e.target.name will be name of originating input field
*/
const handleInput = e => {
const { name, value } = e.target;
// We arbitrarily cap input length on all formData fields on this page
if (name !== 'addressToVerify') {
setFormDataError(previous => ({
...previous,
[name]:
value.length > CASHTAB_MESSAGE_MAX_LENGTH
? `Cashtab supports msgs up to ${CASHTAB_MESSAGE_MAX_LENGTH} characters.`
: false,
}));
} else if (name === 'addressToVerify') {
// Validate addressToVerify
const isValidAddr = isValidCashAddress(value, appConfig.prefix);
setFormDataError(previous => ({
...previous,
[name]: isValidAddr ? false : 'Invalid cash address',
}));
} else if (name === 'signatureToVerify') {
setFormDataError(previous => ({
...previous,
[name]:
value.length !== ECASH_SIGNED_MSG_LENGTH
? `Invalid eCash signature length`
: false,
}));
}
setFormData(previous => ({
...previous,
[name]: value,
}));
};
const verifyMessage = () => {
let verification;
try {
verification = verifyMsg(
formData.msgToVerify,
formData.signatureToVerify,
formData.addressToVerify,
);
} catch (err) {
toast.error(`${err}`);
}
if (verification) {
toast.success(
`Signature verified. Message "${formData.msgToVerify}" was signed by ${formData.addressToVerify}`,
);
} else {
toast.error('Signature does not match address and message');
}
};
return (
<SignVerifyForm title="Sign & Verify">
<PageHeader>
Sign & Verify Msg
<ThemedSignAndVerifyMsg />
</PageHeader>
<Row>
<Switch
name="Toggle Sign Verify"
on="✍️ Sign"
off="✅ Verify"
bgColorOff={theme.genesisGreen}
width={110}
right={76}
checked={signMsgMode}
handleToggle={() => setSignMsgMode(!signMsgMode)}
/>
</Row>
{signMsgMode ? (
<>
<Row>
<TextArea
placeholder={`Enter message to sign`}
name="msgToSign"
handleInput={handleInput}
value={formData.msgToSign}
error={formDataError.msgToSign}
showCount
max={`${CASHTAB_MESSAGE_MAX_LENGTH}`}
/>
</Row>
<Row>
<PrimaryButton
onClick={handleUserSignature}
disabled={formData.msgToSign === ''}
>
Sign
</PrimaryButton>
</Row>
{messageSignature !== '' && (
<>
<Row>
<SignatureLabel>Signature:</SignatureLabel>
</Row>
<Row>
<CopyToClipboard
data={messageSignature}
showToast
>
<SignatureHolder>
{messageSignature}
</SignatureHolder>
</CopyToClipboard>
</Row>
</>
)}
</>
) : (
<>
<Row>
<TextArea
placeholder={`Enter message to verify`}
name="msgToVerify"
handleInput={handleInput}
value={formData.msgToVerify}
error={formDataError.msgToVerify}
showCount
max={`${CASHTAB_MESSAGE_MAX_LENGTH}`}
/>
</Row>
<Row>
<Input
name="addressToVerify"
placeholder="Enter address of signature to verify"
value={formData.addressToVerify}
error={formDataError.addressToVerify}
handleInput={handleInput}
/>
</Row>
<Row>
<TextArea
placeholder={`Enter signature to verify`}
name="signatureToVerify"
handleInput={handleInput}
value={formData.signatureToVerify}
error={formDataError.signatureToVerify}
/>
</Row>
<Row>
<SecondaryButton
onClick={verifyMessage}
disabled={
formDataError.msgToVerify !== false ||
formDataError.addressToVerify !== false ||
formDataError.signatureToVerify !== false
}
>
Verify
</SecondaryButton>
</Row>
</>
)}
</SignVerifyForm>
);
};
export default SignVerifyMsg;

File Metadata

Mime Type
text/x-diff
Expires
Sun, Apr 27, 11:54 (1 d, 2 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573471
Default Alt Text
(202 KB)

Event Timeline