Page MenuHomePhabricator

No OneTemporary

diff --git a/cashtab/src/components/Home/Home.tsx b/cashtab/src/components/Home/Home.tsx
index e67e5dc06..7ef7f1a84 100644
--- a/cashtab/src/components/Home/Home.tsx
+++ b/cashtab/src/components/Home/Home.tsx
@@ -1,302 +1,303 @@
// 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, useContext } from 'react';
import styled from 'styled-components';
import { WalletContext, isWalletContextLoaded } from 'wallet/context';
import { Link } from 'react-router-dom';
import TxHistory from './TxHistory';
import ApiError from 'components/Common/ApiError';
import Receive from 'components/Receive/Receive';
import { Alert, Info } from 'components/Common/Atoms';
import { getUserLocale } from 'helpers';
import { CashtabPathInfo, getHashes, CashtabTx } from 'wallet';
import PrimaryButton, {
SecondaryButton,
PrimaryLink,
SecondaryLink,
} from 'components/Common/Buttons';
import { toast } from 'react-toastify';
import { token as tokenConfig } from 'config/token';
import { InlineLoader } from 'components/Common/Spinner';
import { load } from 'recaptcha-v3';
export const Tabs = styled.div`
margin: auto;
display: inline-block;
text-align: center;
width: 100%;
margin: 20px 0;
`;
export const TxHistoryCtn = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
color: ${props => props.theme.primaryText};
background-color: ${props => props.theme.primaryBackground};
padding: 20px;
border-radius: 10px;
@media (max-width: 768px) {
border-radius: 0;
+ padding: 10px;
}
`;
export const AlertLink = styled(Link)`
color: red;
:hover {
color: #000;
}
`;
export const AddrSwitchContainer = styled.div`
text-align: center;
padding: 6px 0 12px 0;
`;
export const AirdropButton = styled(PrimaryButton)`
margin-bottom: 0;
div {
margin: auto;
}
`;
export const TokenRewardButton = styled(SecondaryButton)`
margin-bottom: 0;
div {
margin: auto;
}
`;
const Home: React.FC = () => {
const ContextValue = useContext(WalletContext);
if (!isWalletContextLoaded(ContextValue)) {
// Confirm we have all context required to load the page
return null;
}
const {
fiatPrice,
apiError,
cashtabState,
updateCashtabState,
chaintipBlockheight,
} = ContextValue;
const { settings, wallets } = cashtabState;
const wallet = wallets[0];
const hashes = getHashes(wallet);
const { parsedTxHistory } = wallet.state;
const hasHistory = parsedTxHistory && parsedTxHistory.length > 0;
// Want to show a msg to users who have just claimed a free XEC reward, or users who have just received
// a few txs
const isNewishWallet =
hasHistory &&
parsedTxHistory &&
parsedTxHistory.length < 3 &&
wallet.state.balanceSats > 0;
const userLocale = getUserLocale(navigator);
const [airdropPending, setAirdropPending] = useState(false);
const [tokenRewardsPending, setTokenRewardsPending] = useState(false);
const claimAirdropForNewWallet = async () => {
if (typeof process.env.REACT_APP_RECAPTCHA_SITE_KEY === 'undefined') {
// Recaptcha env var must be set to claimAirdropForNewWallet
return;
}
// Disable the button to prevent double claims
setAirdropPending(true);
const recaptcha = await load(process.env.REACT_APP_RECAPTCHA_SITE_KEY);
const token = await recaptcha.execute('claimxec');
// Claim rewards
// We only show this option if wallet has no tx history. Such a wallet is always
// expected to be eligible.
let claimResponse;
try {
claimResponse = await (
await fetch(
`${tokenConfig.rewardsServerBaseUrl}/claimxec/${
(wallet.paths.get(1899) as CashtabPathInfo).address
}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
},
)
).json();
// Could help in debugging from user reports
console.info(claimResponse);
if ('error' in claimResponse) {
throw new Error(`${claimResponse.error}:${claimResponse.msg}`);
}
toast.success('Free eCash claimed!');
// Note we do not setAirdropPending(false) on a successful claim
// The button will disappear when the tx is seen by the wallet
// We do not want the button to be enabled before this
} catch (err) {
setAirdropPending(false);
console.error(err);
toast.error(`${err}`);
}
};
const claimTokenRewardsForNewWallet = async () => {
// Disable the button to prevent double claims
setTokenRewardsPending(true);
// Claim rewards
// We only show this option if wallet has no tx history. Such a wallet is always
// expected to be eligible.
let claimResponse;
try {
claimResponse = await (
await fetch(
`${tokenConfig.rewardsServerBaseUrl}/claim/${
(wallet.paths.get(1899) as CashtabPathInfo).address
}`,
)
).json();
// Could help in debugging from user reports
console.info(claimResponse);
if ('error' in claimResponse) {
throw new Error(`${claimResponse.error}:${claimResponse.msg}`);
}
toast.success(
'Token rewards claimed! Check "Rewards" menu option for more.',
);
// Note we do not setTokenRewardsPending(false) on a successful claim
// The button will disappear when the tx is seen by the wallet
// We do not want the button to be enabled before this
} catch (err) {
setTokenRewardsPending(false);
console.error(err);
toast.error(`${err}`);
}
};
/**
* Type guard for latest CashtabTx[] type
* Prevents rendering tx history until wallet has loaded (or migrated)
* latest tx history
*/
const isValidParsedTxHistory = (
parsedTxHistory: CashtabTx[],
): parsedTxHistory is CashtabTx[] => {
if (Array.isArray(parsedTxHistory)) {
if (parsedTxHistory.length > 0) {
const testEntry = parsedTxHistory[0];
// Migrated parsedTxHistory has appActions array in every tx
return Array.isArray(testEntry.parsed.appActions);
}
// Empty array is always valid
return true;
}
return false;
};
return (
<>
{apiError && <ApiError />}
<TxHistoryCtn data-testid="tx-history">
{isValidParsedTxHistory(parsedTxHistory) ? (
<TxHistory
txs={parsedTxHistory}
hashes={hashes}
fiatPrice={fiatPrice}
fiatCurrency={
settings && settings.fiatCurrency
? settings.fiatCurrency
: 'usd'
}
cashtabState={cashtabState}
updateCashtabState={updateCashtabState}
userLocale={userLocale}
chaintipBlockheight={chaintipBlockheight}
/>
) : (
<InlineLoader />
)}
{isNewishWallet && (
<>
<Info style={{ marginBottom: '20px' }}>
ℹ️ Nice, you have some eCash. What can you do?
</Info>
<PrimaryLink to="/create-token">
Create a token
</PrimaryLink>
<SecondaryLink to="/create-nft-collection">
Mint an NFT
</SecondaryLink>
<Info>
💰 You could also earn more by monetizing your
content at{' '}
<a
href="https://ecashchat.com/"
target="_blank"
rel="noreferrer"
>
eCashChat.
</a>
</Info>
</>
)}
{!hasHistory && (
<>
<Alert>
<p>
<b>
<AlertLink to="/backup">
Backup your wallet
</AlertLink>
</b>
</p>
<p>
Write down your 12-word seed and keep it in a
safe place.{' '}
<em>Do not share your backup with anyone.</em>
</p>
</Alert>
{process.env.REACT_APP_BUILD_ENV !== 'extension' &&
process.env.REACT_APP_TESTNET !== 'true' && (
<>
{wallets.length === 1 ? (
<AirdropButton
onClick={claimAirdropForNewWallet}
disabled={airdropPending}
>
{airdropPending ? (
<InlineLoader />
) : (
'Claim Free XEC'
)}
</AirdropButton>
) : (
<TokenRewardButton
onClick={
claimTokenRewardsForNewWallet
}
disabled={tokenRewardsPending}
>
{tokenRewardsPending ? (
<InlineLoader />
) : (
'Claim Token Rewards'
)}
</TokenRewardButton>
)}
</>
)}
<Receive />
</>
)}
</TxHistoryCtn>
</>
);
};
export default Home;
diff --git a/cashtab/src/components/Home/Tx/index.tsx b/cashtab/src/components/Home/Tx/index.tsx
index dea18c379..6dd116287 100644
--- a/cashtab/src/components/Home/Tx/index.tsx
+++ b/cashtab/src/components/Home/Tx/index.tsx
@@ -1,1124 +1,1156 @@
// 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 {
TxWrapper,
MainRow,
TxDescCol,
TxDesc,
Timestamp,
AmountCol,
AmountTop,
AmountBottom,
Expand,
MainRowLeft,
IconCtn,
PanelButton,
Collapse,
PanelLink,
ReplyLink,
AppAction,
AppDescLabel,
AppDescMsg,
TokenActionHolder,
TokenAction,
TokenDesc,
TokenType,
TokenName,
TokenTicker,
TokenInfoCol,
UnknownMsgColumn,
ActionLink,
IconAndLabel,
AddressLink,
ExpandButtonPanel,
TxDescSendRcvMsg,
Ellipsis,
TimestampSeperator,
MessageLabel,
+ AirdropHeader,
+ AirdropIconCtn,
} from 'components/Home/Tx/styled';
import {
SendIcon,
ReceiveIcon,
MinedIcon,
AliasIconTx,
GenesisIcon,
AirdropIcon,
EncryptedMsgIcon,
TokenBurnIcon,
SwapIcon,
PayButtonIcon,
ChatIcon,
MintIcon,
UnknownIcon,
CashtabMsgIcon,
CopyPasteIcon,
ThemedPdfSolid,
ThemedLinkSolid,
AddContactIcon,
ReplyIcon,
SelfSendIcon,
FanOutIcon,
MintNftIcon,
PaywallPaymentIcon,
AgoraOfferIcon,
AgoraBuyIcon,
AgoraSaleIcon,
AgoraCancelIcon,
TokenSendIcon,
XecxIcon,
FirmaIcon,
} from 'components/Common/CustomIcons';
import CashtabSettings, {
supportedFiatCurrencies,
} from 'config/CashtabSettings';
import CopyToClipboard from 'components/Common/CopyToClipboard';
import { explorer } from 'config/explorer';
import { ParsedTokenTxType, XecxAction } from 'chronik';
import { toFormattedXec, decimalizedTokenQtyToLocaleFormat } from 'formatting';
import {
toXec,
decimalizeTokenAmount,
CashtabTx,
SlpDecimals,
CashtabWallet,
LegacyCashtabWallet,
} from 'wallet';
import { opReturn } from 'config/opreturn';
import TokenIcon from 'components/Etokens/TokenIcon';
import Modal from 'components/Common/Modal';
import { ModalInput } from 'components/Common/Inputs';
import { toast } from 'react-toastify';
import { getContactNameError } from 'validation';
import AvalancheFinalized from 'components/Common/AvalancheFinalized';
import CashtabState, { CashtabContact } from 'config/CashtabState';
import CashtabCache from 'config/CashtabCache';
import { CashtabCacheJson, StoredCashtabWallet } from 'helpers';
interface TxProps {
tx: CashtabTx;
hashes: string[];
fiatPrice: null | number;
fiatCurrency: string;
cashtabState: CashtabState;
updateCashtabState: (
key: string,
value:
| CashtabWallet[]
| CashtabCache
| CashtabContact[]
| CashtabSettings
| CashtabCacheJson
| StoredCashtabWallet[]
| (LegacyCashtabWallet | StoredCashtabWallet)[],
) => Promise<boolean>;
chaintipBlockheight: number;
userLocale: string;
}
const Tx: React.FC<TxProps> = ({
tx,
fiatPrice,
fiatCurrency,
cashtabState,
updateCashtabState,
chaintipBlockheight,
userLocale = 'en-US',
}) => {
const { txid, timeFirstSeen, block, parsed } = tx;
const {
satoshisSent,
xecTxType,
recipients,
appActions,
replyAddress,
parsedTokenEntries,
} = parsed;
const { cashtabCache, contactList } = cashtabState;
const replyAddressPreview =
typeof replyAddress !== 'undefined'
? `${replyAddress.slice(6, 9)}...${replyAddress.slice(-3)}`
: undefined;
const knownSender = contactList.find(
contact => contact.address === replyAddress,
);
let knownRecipient, renderedRecipient, renderedOtherRecipients;
if (xecTxType === 'Sent' && typeof recipients[0] !== 'undefined') {
const recipientPreview = `${recipients[0].slice(
6,
9,
)}...${recipients[0].slice(-3)}`;
knownRecipient = contactList.find(
contact => contact.address === recipients[0],
);
renderedRecipient = `${
typeof knownRecipient !== 'undefined'
? knownRecipient.name
: recipientPreview
}`;
renderedOtherRecipients =
recipients.length > 1
? `and ${recipients.length - 1} other${
recipients.length - 1 > 1 ? 's' : ''
}`
: '';
}
const renderedAppActions: React.ReactNode[] = [];
// Add firma yield if applicable
// NB firma yield is not not identified by OP_RETURN
// But it is, semantically speaking, an "app action"
// A firma yield payment is identified as
// - received firma
// - sending address was firma yield wallet
const isFirmaYield =
!tx.isCoinbase &&
tx.inputs[0].outputScript ===
'76a91438d2e1501a485814e2849552093bb0588ed9acbb88ac' &&
typeof parsed.parsedTokenEntries[0] !== 'undefined' &&
parsed.parsedTokenEntries[0].tokenId ===
'0387947fd575db4fb19a3e322f635dec37fd192b5941625b66bc4b2c3008cbf0';
if (isFirmaYield) {
renderedAppActions.push(
<IconAndLabel>
<FirmaIcon />
<AppDescLabel noWordBreak>Firma yield payment</AppDescLabel>
</IconAndLabel>,
);
}
for (const appAction of appActions) {
const { lokadId, app, isValid, action } = appAction;
switch (lokadId) {
case opReturn.appPrefixesHex.aliasRegistration: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<AliasIconTx />
<AppDescLabel>
Invalid alias registration
</AppDescLabel>
</IconAndLabel>,
);
} else {
if (typeof action !== 'undefined' && 'alias' in action) {
// Type guard, we know that all valid aliases will have this action
// from the parseTx function
const { alias, address } = action;
const aliasAddrPreview = `${address.slice(
6,
9,
)}...${address.slice(-3)}`;
renderedAppActions.push(
<>
<IconAndLabel>
<AliasIconTx />
<AppDescLabel>
Alias Registration
</AppDescLabel>
</IconAndLabel>
<AppDescMsg>
{`${alias} to ${aliasAddrPreview}`}
</AppDescMsg>
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.airdrop: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<AirdropIcon />
<AppDescLabel>
Off-spec airdrop: tokenId unavailable
</AppDescLabel>
</IconAndLabel>,
);
} else {
if (typeof action !== 'undefined' && 'tokenId' in action) {
const { tokenId, msg } = action;
// Add token info if we have it in cache
const airdroppedTokenInfo =
cashtabCache.tokens.get(tokenId);
if (typeof airdroppedTokenInfo !== 'undefined') {
const { genesisInfo } = airdroppedTokenInfo;
const { tokenName, tokenTicker } = genesisInfo;
renderedAppActions.push(
<>
- <IconAndLabel>
- <AirdropIcon />
- <AppDescLabel>
- Airdrop (XEC)
- </AppDescLabel>
- </IconAndLabel>
- <TokenIcon size={32} tokenId={tokenId} />
- <TokenInfoCol>
- <TokenName to={`/token/${tokenId}`}>
- {tokenName}
- </TokenName>
- <TokenTicker>
- ({tokenTicker})
- </TokenTicker>
- </TokenInfoCol>
+ <AirdropHeader
+ message={typeof msg !== 'undefined'}
+ >
+ <IconAndLabel>
+ <AirdropIcon />
+ <AppDescLabel>
+ Airdrop (XEC)
+ </AppDescLabel>
+ </IconAndLabel>
+ <AirdropIconCtn>
+ <TokenIcon
+ size={32}
+ tokenId={tokenId}
+ />
+ <TokenInfoCol>
+ <TokenName
+ to={`/token/${tokenId}`}
+ >
+ {tokenName}
+ </TokenName>
+ <TokenTicker>
+ ({tokenTicker})
+ </TokenTicker>
+ </TokenInfoCol>
+ </AirdropIconCtn>
+ </AirdropHeader>
{typeof msg !== 'undefined' && (
- <AppDescMsg>{msg}</AppDescMsg>
+ <>
+ <MessageLabel>
+ <CashtabMsgIcon />
+ Airdrop Msg
+ </MessageLabel>
+ <AppDescMsg>{msg}</AppDescMsg>
+ </>
)}
</>,
);
break;
}
// If we do not have token info
renderedAppActions.push(
<>
- <IconAndLabel>
- <AirdropIcon />
- <AppDescLabel>
- Airdrop to holders of{' '}
- <ActionLink
- href={`${explorer.blockExplorerUrl}/tx/${tokenId}`}
- target="_blank"
- rel="noreferrer"
- >
- {`${tokenId.slice(
- 0,
- 3,
- )}...${tokenId.slice(-3)}`}
- </ActionLink>
- </AppDescLabel>
- </IconAndLabel>
- <TokenIcon size={32} tokenId={tokenId} />
+ <AirdropHeader
+ message={typeof msg !== 'undefined'}
+ >
+ <IconAndLabel>
+ <AirdropIcon />
+ <AppDescLabel>
+ Airdrop to holders of{' '}
+ <ActionLink
+ href={`${explorer.blockExplorerUrl}/tx/${tokenId}`}
+ target="_blank"
+ rel="noreferrer"
+ >
+ {`${tokenId.slice(
+ 0,
+ 3,
+ )}...${tokenId.slice(-3)}`}
+ </ActionLink>
+ </AppDescLabel>
+ </IconAndLabel>
+ <TokenIcon size={32} tokenId={tokenId} />
+ </AirdropHeader>
{typeof msg !== 'undefined' && (
- <AppDescMsg>{msg}</AppDescMsg>
+ <>
+ <MessageLabel>
+ <CashtabMsgIcon />
+ Airdrop Msg
+ </MessageLabel>
+ <AppDescMsg>{msg}</AppDescMsg>
+ </>
)}
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.cashtabEncrypted: {
// Deprecated, just print the app
renderedAppActions.push(
<IconAndLabel>
<EncryptedMsgIcon />
<AppDescLabel>{app}</AppDescLabel>
</IconAndLabel>,
);
break;
}
case opReturn.appPrefixesHex.swap: {
// Limited parse support for SWaP txs, which are not expected in Cashtab
// Just print the type
renderedAppActions.push(
<IconAndLabel>
<SwapIcon />
<AppDescLabel>{app}</AppDescLabel>
</IconAndLabel>,
);
break;
}
case opReturn.appPrefixesHex.paybutton: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<PayButtonIcon />
<AppDescLabel>Invalid {app}</AppDescLabel>
</IconAndLabel>,
);
} else {
if (typeof action !== 'undefined' && 'data' in action) {
const { data, nonce } = action;
// Valid PayButtonTx
renderedAppActions.push(
<>
<IconAndLabel>
<PayButtonIcon />
</IconAndLabel>
{data !== '' && <AppDescMsg>{data}</AppDescMsg>}
{nonce !== '' && (
<AppDescMsg>{nonce}</AppDescMsg>
)}
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.eCashChat: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<ChatIcon />
<AppDescLabel>Invalid {app}</AppDescLabel>
</IconAndLabel>,
);
} else {
if (typeof action !== 'undefined' && 'msg' in action) {
const { msg } = action;
renderedAppActions.push(
<>
<IconAndLabel>
<ChatIcon />
<AppDescLabel>{app}</AppDescLabel>
</IconAndLabel>
<AppDescMsg>{msg}</AppDescMsg>
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.paywallPayment: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<PaywallPaymentIcon />
<AppDescLabel>Invalid {app}</AppDescLabel>
</IconAndLabel>,
);
} else {
if (
typeof action !== 'undefined' &&
'sharedArticleTxid' in action
) {
const { sharedArticleTxid } = action;
renderedAppActions.push(
<>
<IconAndLabel>
<PaywallPaymentIcon />
<AppDescLabel>{app}</AppDescLabel>
</IconAndLabel>
<AppDescMsg>
<a
href={`https://www.ecashchat.com/?sharedArticleTxid=${sharedArticleTxid}`}
target="_blank"
rel="noreferrer"
>
Paywall Article
</a>
</AppDescMsg>
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.authPrefixHex: {
// If it has the lokad, it's valid
renderedAppActions.push(
<IconAndLabel>
<ChatIcon />
<AppDescLabel>{app}</AppDescLabel>
</IconAndLabel>,
);
break;
}
case opReturn.appPrefixesHex.eCashChatArticle: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<ChatIcon />
<AppDescLabel>
Invalid eCashChat Article
</AppDescLabel>
</IconAndLabel>,
);
} else {
if (
typeof action !== 'undefined' &&
'replyArticleTxid' in action
) {
const { replyArticleTxid, msg } = action;
renderedAppActions.push(
<>
<IconAndLabel>
<ChatIcon />
<AppDescLabel>
eCash Chat - Reply to
<a
href={`https://www.ecashchat.com/?sharedArticleTxid=${replyArticleTxid}`}
target="_blank"
rel="noreferrer"
>
&nbsp;article
</a>
</AppDescLabel>
</IconAndLabel>
<AppDescMsg>{msg}</AppDescMsg>
</>,
);
} else {
renderedAppActions.push(
<IconAndLabel>
<ChatIcon />
<AppDescLabel>
eCash Chat article created
</AppDescLabel>
</IconAndLabel>,
);
}
}
break;
}
case opReturn.appPrefixesHex.cashtab: {
if (!isValid) {
renderedAppActions.push(
<IconAndLabel>
<CashtabMsgIcon />
<AppDescLabel>Invalid Cashtab Msg</AppDescLabel>
</IconAndLabel>,
);
} else {
if (typeof action !== 'undefined' && 'msg' in action) {
const { msg } = action;
renderedAppActions.push(
<>
<MessageLabel>
<CashtabMsgIcon />
Cashtab Msg
</MessageLabel>
<AppDescMsg>{msg}</AppDescMsg>
{xecTxType === 'Received' &&
typeof replyAddress !== 'undefined' && (
<ReplyLink
to="/send"
state={{
replyAddress: replyAddress,
}}
>
<ReplyIcon />
</ReplyLink>
)}
</>,
);
}
}
break;
}
case opReturn.appPrefixesHex.xecx: {
if (isValid) {
const { minBalanceTokenSatoshisToReceivePaymentThisRound } =
action as XecxAction;
const minBalanceXec = toXec(
minBalanceTokenSatoshisToReceivePaymentThisRound,
);
renderedAppActions.push(
<IconAndLabel>
<XecxIcon />
<AppDescLabel noWordBreak>
XEC staking reward to all XECX holders with
balance{' '}
{`>= ${minBalanceXec.toLocaleString(
userLocale,
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)} XECX`}
</AppDescLabel>
</IconAndLabel>,
);
} else {
// invalid xecx
renderedAppActions.push(
<IconAndLabel>
<XecxIcon />
<AppDescLabel>Invalid XECX EMPP</AppDescLabel>
</IconAndLabel>,
);
}
break;
}
case 'unknown': {
if (typeof action !== 'undefined' && 'decoded' in action) {
const { stack, decoded } = action;
renderedAppActions.push(
<>
<IconAndLabel>
<UnknownIcon />
<AppDescLabel>
Unknown LokadId {lokadId}
</AppDescLabel>
</IconAndLabel>
<UnknownMsgColumn>
<AppDescMsg>{stack}</AppDescMsg>
<AppDescMsg>{decoded}</AppDescMsg>
</UnknownMsgColumn>
</>,
);
}
break;
}
default: {
if (typeof action !== 'undefined' && 'decoded' in action) {
const { stack, decoded } = action;
renderedAppActions.push(
<>
<IconAndLabel>
<UnknownIcon />
<AppDescLabel>Unknown App</AppDescLabel>
</IconAndLabel>
<UnknownMsgColumn>
<AppDescMsg>{stack}</AppDescMsg>
<AppDescMsg>{decoded}</AppDescMsg>
</UnknownMsgColumn>
</>,
);
} else {
renderedAppActions.push(
<IconAndLabel>
<UnknownIcon />
<AppDescLabel>Unknown App</AppDescLabel>
</IconAndLabel>,
);
}
break;
}
}
}
const tokenActions: React.ReactNode[] = [];
for (const parsedTokenEntry of parsedTokenEntries) {
// Note that parsedTokenEntries[i] correspondes to tokenEntries[i]
// Not used for now but could be used for other rendering cases
const {
tokenId,
renderedTokenType,
renderedTxType,
tokenSatoshis,
nftFanInputsCreated,
} = parsedTokenEntry;
// Every token entry has a token icon
const tokenIcon = <TokenIcon size={32} tokenId={tokenId} />;
// Get action icon based on renderedTxType
let actionIcon: React.ReactNode;
switch (renderedTxType) {
case 'GENESIS': {
if (renderedTokenType === 'NFT') {
actionIcon = <MintNftIcon />;
} else {
actionIcon = <GenesisIcon />;
}
break;
}
case 'MINT': {
actionIcon = <MintIcon />;
break;
}
case 'UNKNOWN':
case 'NONE': {
// We get the NONE type for NFT mints on the parsedTokenEntry that burns
// a qty-1 COLLECTION token
if (renderedTokenType === 'Collection') {
actionIcon = <TokenBurnIcon />;
} else {
actionIcon = <UnknownIcon />;
}
break;
}
case 'SEND': {
actionIcon = <TokenSendIcon />;
break;
}
case 'BURN': {
actionIcon = <TokenBurnIcon />;
break;
}
case ParsedTokenTxType.AgoraBuy: {
actionIcon = <AgoraBuyIcon />;
break;
}
case ParsedTokenTxType.AgoraSale: {
actionIcon = <AgoraSaleIcon />;
break;
}
case ParsedTokenTxType.AgoraCancel: {
actionIcon = <AgoraCancelIcon />;
break;
}
case ParsedTokenTxType.AgoraOffer: {
actionIcon = <AgoraOfferIcon />;
break;
}
case ParsedTokenTxType.FanOut: {
actionIcon = <FanOutIcon />;
break;
}
default: {
// We could handle UNKNOWN and NONE types here
// But keep them split out to show they are possible types
// We may want them to have distinct rendering in the future
actionIcon = <UnknownIcon />;
break;
}
}
// Token name and ticker depends on availability of cache info
const cachedTokenInfo = cashtabCache.tokens.get(tokenId);
let tokenTicker: string;
let tokenName: string;
let decimals: undefined | number;
if (typeof cachedTokenInfo === 'undefined') {
tokenName = `${tokenId.slice(0, 3)}...${tokenId.slice(-3)}`;
tokenTicker = '';
// Leave decimals as undefined, we will not use it if we do not have it
} else {
({ tokenName, tokenTicker, decimals } =
cachedTokenInfo.genesisInfo);
}
const decimalizedAmount =
typeof decimals === 'number'
? decimalizeTokenAmount(tokenSatoshis, decimals as SlpDecimals)
: '0';
const formattedAmount =
typeof decimals === 'number'
? decimalizedTokenQtyToLocaleFormat(
decimalizedAmount,
userLocale,
)
: '0';
tokenActions.push(
- <TokenAction tokenTxType={renderedTxType}>
+ <TokenAction
+ tokenTxType={renderedTxType}
+ noWordBreak={tokenName.length < 10 || tokenTicker.length < 10}
+ >
<IconAndLabel>
{actionIcon}
{tokenIcon}
<TokenInfoCol>
<TokenType>
{renderedTokenType === 'Collection' &&
renderedTxType === 'NONE'
? 'BURN'
: renderedTxType}
</TokenType>
</TokenInfoCol>
</IconAndLabel>
<TokenInfoCol>
<TokenName to={`/token/${tokenId}`}>{tokenName}</TokenName>
{tokenTicker !== '' && (
<TokenTicker>({tokenTicker})</TokenTicker>
)}
</TokenInfoCol>
<TokenDesc>
{renderedTxType === ParsedTokenTxType.FanOut
? `Created ${nftFanInputsCreated} NFT Mint Input${
(nftFanInputsCreated as number) > 1 ? 's' : ''
}`
: renderedTxType === ParsedTokenTxType.AgoraOffer
? `Listed ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === ParsedTokenTxType.AgoraBuy
? `Bought ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === ParsedTokenTxType.AgoraSale
? `Sold ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === ParsedTokenTxType.AgoraCancel
? `Canceled offer ${
typeof decimals === 'number'
? `of ${formattedAmount}`
: ''
} ${tokenTicker}`
: renderedTxType === 'BURN'
? `Burned ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === 'SEND'
? `${xecTxType} ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === 'MINT' ||
(renderedTxType === 'GENESIS' &&
renderedTokenType === 'NFT')
? `Minted ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === 'NONE' &&
renderedTokenType === 'Collection'
? `Burned 1 ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: renderedTxType === 'GENESIS'
? `Created ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`
: `${renderedTxType} ${
typeof decimals === 'number'
? formattedAmount
: ''
} ${tokenTicker}`}
</TokenDesc>
</TokenAction>,
);
}
const [showPanel, setShowPanel] = useState(false);
const [showAddNewContactModal, setShowAddNewContactModal] = useState(false);
interface TxFormData {
newContactName: string;
}
const emptyFormData: TxFormData = {
newContactName: '',
};
interface TxFormDataErrors {
newContactName: false | string;
}
const emptyFormDataErrors: TxFormDataErrors = {
newContactName: false,
};
const [formData, setFormData] = useState<TxFormData>(emptyFormData);
const [formDataErrors, setFormDataErrors] =
useState<TxFormDataErrors>(emptyFormDataErrors);
const addNewContact = async (addressToAdd: string) => {
// Check to see if the contact exists
const contactExists = contactList.find(
contact => contact.address === addressToAdd,
);
if (typeof contactExists !== 'undefined') {
// Contact exists
// Not expected to ever happen from Tx.js as user should not see option to
// add an existing contact
toast.error(`${addressToAdd} already exists in Contacts`);
} else {
contactList.push({
name: formData.newContactName,
address: addressToAdd,
});
// update localforage and state
await updateCashtabState('contactList', contactList);
toast.success(
`${formData.newContactName} (${addressToAdd}) added to Contact List`,
);
}
// Reset relevant state fields
setShowAddNewContactModal(false);
// Clear new contact formData
setFormData(previous => ({
...previous,
newContactName: '',
}));
};
/**
* 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: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
if (name === 'newContactName') {
const contactNameError = getContactNameError(value, contactList);
setFormDataErrors(previous => ({
...previous,
[name]: contactNameError,
}));
}
setFormData(previous => ({
...previous,
[name]: value,
}));
};
/**
* We use timeFirstSeen if it is not 0
* We use block.timestamp if timeFirstSeen is unavailable
* We use Date.now() if timeFirstSeen is 0 and we do not have a block timestamp
* This is an edge case that is unlikely to ever be seen -- requires the chronik node to have just
* become indexed after a tx was broadcast but before it is confirmed
*/
const timestamp =
timeFirstSeen !== 0 || typeof block?.timestamp !== 'undefined'
? new Date(
parseInt(
`${
timeFirstSeen !== 0 ? timeFirstSeen : block?.timestamp
}000`,
),
)
: new Date();
const renderedTimestamp = timestamp.toLocaleTimeString(userLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour12: false,
});
const senderOrRecipientNotInContacts =
(xecTxType === 'Received' && typeof knownSender === 'undefined') ||
(xecTxType === 'Sent' &&
typeof knownRecipient === 'undefined' &&
typeof recipients[0] !== 'undefined');
// AgoraCancels can get the satoshisSent === 0 condition
const isSelfSendTx =
(typeof recipients[0] === 'undefined' && xecTxType !== 'Received') ||
satoshisSent === 0;
const handleAmountCopy = (data: string) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(data);
}
toast.success(`"${data}" copied to clipboard`);
};
return (
<>
{showAddNewContactModal && (
<Modal
height={180}
title={`Add new contact`}
handleOk={
xecTxType === 'Sent' &&
typeof recipients[0] !== 'undefined'
? () => addNewContact(recipients[0])
: () => addNewContact(replyAddress as string)
}
handleCancel={() => setShowAddNewContactModal(false)}
showCancelButton
>
<ModalInput
placeholder="Enter new contact name"
name="newContactName"
value={formData.newContactName}
error={formDataErrors.newContactName}
handleInput={handleInput}
/>
</Modal>
)}
<TxWrapper>
<Collapse onClick={() => setShowPanel(!showPanel)}>
<MainRow type={xecTxType}>
<MainRowLeft>
<IconCtn receive={xecTxType === 'Received'}>
{isSelfSendTx ? (
<SelfSendIcon />
) : xecTxType === 'Received' ? (
<ReceiveIcon />
) : xecTxType === 'Sent' ? (
<SendIcon />
) : (
<MinedIcon />
)}
</IconCtn>
<TxDescCol>
<TxDesc>
<TxDescSendRcvMsg>
{!isSelfSendTx ? xecTxType : 'Sent'}
{typeof replyAddress === 'string' &&
!isSelfSendTx ? (
<>
{' from'}
<AddressLink
href={`${explorer.blockExplorerUrl}/address/${replyAddress}`}
target="_blank"
rel="noreferrer"
>
{typeof knownSender ===
'undefined'
? replyAddressPreview
: knownSender.name}
</AddressLink>
</>
) : xecTxType === 'Sent' &&
!isSelfSendTx ? (
<>
{' to'}
<AddressLink
href={`${explorer.blockExplorerUrl}/address/${recipients[0]}`}
target="_blank"
rel="noreferrer"
>
{renderedRecipient}
</AddressLink>
{renderedOtherRecipients !==
'' && (
<AddressLink
href={`${explorer.blockExplorerUrl}/tx/${txid}`}
target="_blank"
rel="noreferrer"
>
{
renderedOtherRecipients
}
</AddressLink>
)}
</>
) : xecTxType === 'Sent' ||
xecTxType === 'Received' ? (
' to self'
) : (
''
)}
</TxDescSendRcvMsg>
</TxDesc>
<Timestamp>
{renderedTimestamp}
<TimestampSeperator>|</TimestampSeperator>
{typeof block !== 'undefined' &&
block.height <= chaintipBlockheight ? (
<AvalancheFinalized
displayed={
typeof block !== 'undefined' &&
block.height <=
chaintipBlockheight
}
/>
) : (
<Ellipsis title="Loading">
Finalizing<span>.</span>
<span>.</span>
<span>.</span>
</Ellipsis>
)}
</Timestamp>
</TxDescCol>
</MainRowLeft>
<AmountCol>
<AmountTop>
{isSelfSendTx ? (
'-'
) : (
<div
onClick={e => {
e.stopPropagation();
handleAmountCopy(
toXec(
satoshisSent,
).toLocaleString(userLocale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
}),
);
}}
>
{xecTxType === 'Sent' ? '-' : ''}
{!showPanel
? toFormattedXec(
satoshisSent,
userLocale,
)
: toXec(
satoshisSent,
).toLocaleString(userLocale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}{' '}
XEC
</div>
)}
</AmountTop>
<AmountBottom>
{isSelfSendTx ? (
''
) : (
<>
{xecTxType === 'Sent' ? '-' : ''}
{
supportedFiatCurrencies[
fiatCurrency
].symbol
}
{fiatPrice !== null &&
(
fiatPrice * toXec(satoshisSent)
).toLocaleString(userLocale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}
</>
)}
</AmountBottom>
</AmountCol>
</MainRow>
{renderedAppActions.map((action, index) => {
return <AppAction key={index}>{action}</AppAction>;
})}
{tokenActions.map((action, index) => {
return (
<TokenActionHolder key={index}>
{action}
</TokenActionHolder>
);
})}
</Collapse>
<Expand showPanel={showPanel}>
<ExpandButtonPanel>
<CopyToClipboard
data={txid}
showToast
customMsg={`Txid "${txid}" copied to clipboard`}
>
<PanelButton>
<CopyPasteIcon />
</PanelButton>
</CopyToClipboard>
<PanelLink
to={`${explorer.blockExplorerUrl}/tx/${txid}`}
target="_blank"
rel="noreferrer"
>
<ThemedLinkSolid />
</PanelLink>
<PanelLink
to={`${explorer.pdfReceiptUrl}/${txid}.pdf`}
target="_blank"
rel="noreferrer"
>
<ThemedPdfSolid />
</PanelLink>
{senderOrRecipientNotInContacts && (
<PanelButton
onClick={() => {
setShowAddNewContactModal(true);
}}
>
<AddContactIcon />
</PanelButton>
)}
</ExpandButtonPanel>
</Expand>
</TxWrapper>
</>
);
};
export default Tx;
diff --git a/cashtab/src/components/Home/Tx/styled.ts b/cashtab/src/components/Home/Tx/styled.ts
index 806d159d3..bb9564955 100644
--- a/cashtab/src/components/Home/Tx/styled.ts
+++ b/cashtab/src/components/Home/Tx/styled.ts
@@ -1,299 +1,369 @@
// 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`
background-color: ${props => props.theme.secondaryBackground}B3;
display: flex;
flex-direction: column;
padding: 12px;
border-radius: 6px;
svg {
width: 33px;
height: 33px;
}
img {
height: 33px;
}
box-sizing: border-box;
*,
*:before,
*:after {
box-sizing: inherit;
}
+ @media (max-width: 768px) {
+ padding: 10px;
+ }
`;
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;
+ @media (max-width: 768px) {
+ flex-direction: column;
+ gap: 8px;
+ align-items: flex-start;
+ }
`;
export const IconCtn = styled.div<{ receive?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
background-color: ${props =>
props.receive
? `${props.theme.accent}20`
: `${props.theme.primaryText}20`};
border-radius: 50%;
padding: 12px;
svg {
width: 25px;
height: 25px;
}
+ @media (max-width: 768px) {
+ padding: 8px;
+ svg {
+ width: 15px;
+ height: 15px;
+ }
+ }
`;
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;
align-items: center;
width: 100%;
text-align: left;
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
color: ${props => props.theme.secondaryText};
margin-top: 4px;
opacity: 0.8;
`;
export const TimestampSeperator = styled.div`
margin: 0 8px;
`;
export const Ellipsis = styled.div`
@keyframes blink {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
span {
opacity: 0;
animation: blink 1.2s infinite;
}
span:nth-child(1) {
animation-delay: 0s;
}
span:nth-child(2) {
animation-delay: 0.2s;
}
span:nth-child(3) {
animation-delay: 0.4s;
}
`;
export const AmountCol = styled.div`
flex-direction: column;
justify-content: center;
align-items: flex-end;
text-align: right;
`;
// Top row of TxAmountCol
export const AmountTop = styled.div`
font-weight: 600;
:hover {
text-decoration: underline;
}
`;
export const AmountBottom = styled.div`
color: ${props => props.theme.secondaryText};
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
`;
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;
margin-top: ${props => (props.showPanel ? '20px' : '0px')};
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;
align-items: center;
gap: 5px;
`;
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 }>`
word-break: break-all;
${props => props.type === 'Received' && Incoming}
background: ${props => props.theme.primaryText}10;
- background: ${props => props.theme.primaryBackground}B3;
+ background: ${props => props.theme.primaryBackground};
padding: 10px;
border-radius: 6px;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
`;
export const AppDescLabel = styled.div<{ noWordBreak?: boolean }>`
- font-weight: bold;
word-break: ${props => (props.noWordBreak ? 'normal' : 'break-all')};
`;
export const MessageLabel = styled.div`
font-size: var(--text-sm);
color: ${props => props.theme.secondaryText};
display: flex;
align-items: center;
svg {
width: 15px;
height: 15px;
margin-right: 6px;
}
`;
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;
width: 100%;
- margin-top: 10px;
-`;
-export const TokenAction = styled(AppAction)<{
- tokenTxType?: string;
-}>`
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
- ${props => props.tokenTxType === 'Received' && Incoming}
- ${props =>
- (props.tokenTxType === 'Created' || props.tokenTxType === 'Minted') &&
- Genesis}
- ${props => props.tokenTxType === 'Burned' && Burn}
+ margin-top: 5px;
`;
+
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``;
+
+export const TokenAction = styled(AppAction)<{
+ tokenTxType?: string;
+ noWordBreak?: boolean;
+}>`
+ word-break: ${props => (props.noWordBreak ? 'normal' : 'break-all')};
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ ${props => props.tokenTxType === 'Received' && Incoming}
+ ${props =>
+ (props.tokenTxType === 'Created' || props.tokenTxType === 'Minted') &&
+ Genesis}
+ ${props => props.tokenTxType === 'Burned' && Burn}
+ @media (max-width: 768px) {
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ ${TokenInfoCol} {
+ text-align: left;
+ }
+
+ ${TokenDesc} {
+ text-align: right;
+ margin-left: 10px;
+ }
+
+ ${IconAndLabel} {
+ gap: 3px;
+ flex-direction: column;
+ margin-right: 20px;
+ word-break: normal;
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+
+ img {
+ width: 20px;
+ height: 20px;
+ }
+ }
+ }
+ ${IconAndLabel} {
+ svg {
+ fill: ${props =>
+ props.tokenTxType === 'GENESIS'
+ ? props => props.theme.genesisGreen
+ : 'white'};
+ }
+ }
+`;
+
+export const AirdropHeader = styled.div<{ message?: boolean }>`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ flex-wrap: wrap;
+ margin-bottom: ${props => (props.message ? '20px' : '0')};
+`;
+
+export const AirdropIconCtn = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+`;

File Metadata

Mime Type
text/x-diff
Expires
Thu, May 22, 00:15 (1 d, 34 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5866197
Default Alt Text
(71 KB)

Event Timeline