Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F14865001
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
71 KB
Subscribers
None
View Options
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"
>
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
Details
Attached
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)
Attached To
rABC Bitcoin ABC
Event Timeline
Log In to Comment