feat: add taproot txs in activity list, closes #3249

This commit is contained in:
alter-eggo
2023-08-03 13:51:02 +04:00
committed by Anastasios
parent 4903f9ab84
commit d4b1065b0b
16 changed files with 356 additions and 69 deletions

View File

@@ -166,6 +166,7 @@
"@tanstack/react-query-devtools": "4.32.0",
"@tanstack/react-query-persist-client": "4.32.0",
"@tippyjs/react": "4.2.6",
"@types/lodash.uniqby": "4.7.7",
"@typescript-eslint/eslint-plugin": "5.60.1",
"@vkontakte/vk-qr": "2.0.13",
"@zondax/ledger-stacks": "1.0.4",
@@ -195,6 +196,7 @@
"ledger-bitcoin": "0.2.2",
"limiter": "2.1.0",
"lodash.get": "4.4.2",
"lodash.uniqby": "4.7.0",
"mdi-react": "9.2.0",
"micro-packed": "0.3.2",
"object-hash": "3.0.0",
@@ -209,6 +211,7 @@
"react-dom": "18.2.0",
"react-hot-toast": "2.4.1",
"react-icons": "4.10.1",
"react-intersection-observer": "9.5.2",
"react-lottie": "1.2.3",
"react-redux": "8.1.1",
"react-router-dom": "6.14.0",

View File

@@ -1,38 +1,16 @@
import { FiArrowDown as IconArrowDown, FiArrowUp as IconArrowUp } from 'react-icons/fi';
import { Box, BoxProps, Circle, ColorsStringLiteral, Flex, color } from '@stacks/ui';
import { Box, BoxProps, Circle, Flex, color } from '@stacks/ui';
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
import { isBitcoinTxInbound } from '@app/common/transactions/bitcoin/utils';
import { BtcIcon } from '@app/components/icons/btc-icon';
import { IconForTx, colorFromTx } from './utils';
interface TransactionIconProps extends BoxProps {
transaction: BitcoinTx;
btcAddress: string;
}
type BtcTxStatus = 'pending' | 'success';
type BtcStatusColorMap = Record<BtcTxStatus, ColorsStringLiteral>;
const statusFromTx = (tx: BitcoinTx): BtcTxStatus => {
if (tx.status.confirmed) return 'success';
return 'pending';
};
const colorFromTx = (tx: BitcoinTx): ColorsStringLiteral => {
const colorMap: BtcStatusColorMap = {
pending: 'feedback-alert',
success: 'brand',
};
return colorMap[statusFromTx(tx)] ?? 'feedback-error';
};
function IconForTx(address: string, tx: BitcoinTx) {
if (isBitcoinTxInbound(address, tx)) return IconArrowDown;
return IconArrowUp;
}
export function BitcoinTransactionIcon({ transaction, btcAddress, ...rest }: TransactionIconProps) {
return (
<Flex position="relative">

View File

@@ -0,0 +1,68 @@
import { Box, Circle, Flex, color } from '@stacks/ui';
import { SupportedInscription } from '@shared/models/inscription.model';
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
import { OrdinalIcon } from '../icons/ordinal-icon';
import { IconForTx, colorFromTx } from './utils';
interface BitcoinTransactionInscriptionIconProps {
inscription: SupportedInscription;
transaction: BitcoinTx;
btcAddress: string;
}
function InscriptionIcon({ inscription, ...rest }: { inscription: SupportedInscription }) {
switch (inscription.type) {
case 'image':
return (
<Circle
bg={color('accent')}
color={color('bg')}
flexShrink={0}
position="relative"
size="36px"
{...rest}
>
<img
src={inscription.src}
style={{
width: '100%',
height: '100%',
aspectRatio: '1 / 1',
objectFit: 'cover',
borderRadius: '6px',
}}
/>
</Circle>
);
default:
return <OrdinalIcon />;
}
}
export function BitcoinTransactionInscriptionIcon({
inscription,
transaction,
btcAddress,
...rest
}: BitcoinTransactionInscriptionIconProps) {
return (
<Flex position="relative">
<InscriptionIcon inscription={inscription} />
<Circle
bottom="-2px"
right="-9px"
position="absolute"
size="21px"
bg={color(colorFromTx(transaction))}
color={color('bg')}
border="2px solid"
borderColor={color('bg')}
{...rest}
>
<Box size="13px" as={IconForTx(btcAddress, transaction)} />
</Circle>
</Flex>
);
}

View File

@@ -14,19 +14,23 @@ import {
isBitcoinTxInbound,
} from '@app/common/transactions/bitcoin/utils';
import { useWalletType } from '@app/common/use-wallet-type';
import { openInNewTab } from '@app/common/utils/open-in-new-tab';
import { usePressable } from '@app/components/item-hover';
import { IncreaseFeeButton } from '@app/components/stacks-transaction-item/increase-fee-button';
import { TransactionTitle } from '@app/components/transaction/transaction-title';
import { createInscriptionInfoUrl } from '@app/query/bitcoin/ordinals/inscription.hooks';
import { useInscriptionByOutput } from '@app/query/bitcoin/ordinals/use-inscription-by-output';
import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { TransactionItemLayout } from '../transaction-item/transaction-item.layout';
import { BitcoinTransactionCaption } from './bitcoin-transaction-caption';
import { BitcoinTransactionIcon } from './bitcoin-transaction-icon';
import { BitcoinTransactionInscriptionIcon } from './bitcoin-transaction-inscription-icon';
import { BitcoinTransactionStatus } from './bitcoin-transaction-status';
import { BitcoinTransactionValue } from './bitcoin-transaction-value';
interface BitcoinTransactionItemProps extends BoxProps {
transaction?: BitcoinTx;
transaction: BitcoinTx;
}
export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransactionItemProps) {
const [component, bind, { isHovered }] = usePressable(true);
@@ -34,6 +38,8 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
const navigate = useNavigate();
const { whenWallet } = useWalletType();
const { data: inscriptionData } = useInscriptionByOutput(transaction);
const bitcoinAddress = useCurrentAccountNativeSegwitAddressIndexZero();
const { handleOpenTxLink } = useExplorerLink();
const analytics = useAnalytics();
@@ -56,6 +62,10 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
const openTxLink = () => {
void analytics.track('view_bitcoin_transaction');
if (inscriptionData) {
openInNewTab(createInscriptionInfoUrl(inscriptionData.id));
return;
}
handleOpenTxLink({
blockchain: 'bitcoin',
txid: transaction?.txid || '',
@@ -67,7 +77,16 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
const txCaption = <BitcoinTransactionCaption>{caption}</BitcoinTransactionCaption>;
const txValue = <BitcoinTransactionValue>{value}</BitcoinTransactionValue>;
const txIcon = inscriptionData ? (
<BitcoinTransactionInscriptionIcon
inscription={inscriptionData}
transaction={transaction}
btcAddress={bitcoinAddress}
/>
) : (
<BitcoinTransactionIcon transaction={transaction} btcAddress={bitcoinAddress} />
);
const title = inscriptionData ? 'Ordinal inscription' : 'Bitcoin';
const increaseFeeButton = (
<IncreaseFeeButton
isEnabled={isEnabled}
@@ -76,13 +95,14 @@ export function BitcoinTransactionItem({ transaction, ...rest }: BitcoinTransact
onIncreaseFee={onIncreaseFee}
/>
);
return (
<TransactionItemLayout
openTxLink={openTxLink}
txCaption={txCaption}
txIcon={<BitcoinTransactionIcon transaction={transaction} btcAddress={bitcoinAddress} />}
txIcon={txIcon}
txStatus={<BitcoinTransactionStatus transaction={transaction} />}
txTitle={<TransactionTitle title="Bitcoin" />}
txTitle={<TransactionTitle title={title} />}
txValue={txValue}
belowCaptionEl={increaseFeeButton}
{...bind}

View File

@@ -0,0 +1,29 @@
import { FiArrowDown as IconArrowDown, FiArrowUp as IconArrowUp } from 'react-icons/fi';
import { ColorsStringLiteral } from '@stacks/ui-theme';
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
import { isBitcoinTxInbound } from '@app/common/transactions/bitcoin/utils';
type BtcTxStatus = 'pending' | 'success';
type BtcStatusColorMap = Record<BtcTxStatus, ColorsStringLiteral>;
const statusFromTx = (tx: BitcoinTx): BtcTxStatus => {
if (tx.status.confirmed) return 'success';
return 'pending';
};
export const colorFromTx = (tx: BitcoinTx): ColorsStringLiteral => {
const colorMap: BtcStatusColorMap = {
pending: 'feedback-alert',
success: 'brand',
};
return colorMap[statusFromTx(tx)] ?? 'feedback-error';
};
export function IconForTx(address: string, tx: BitcoinTx) {
if (isBitcoinTxInbound(address, tx)) return IconArrowDown;
return IconArrowUp;
}

View File

@@ -1,19 +1,29 @@
export function OrdinalIcon() {
return (
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="18" fill="#0C0C0D" />
<circle cx="18" cy="18" r="12.375" fill="white" />
<rect x="7.32143" y="7.32143" width="21.3571" height="21.3571" rx="10.6786" fill="white" />
<circle cx="18.0001" cy="18" r="4.57143" fill="#0C0C0D" />
<rect
x="7.32143"
y="7.32143"
width="21.3571"
height="21.3571"
rx="10.6786"
stroke="#0C0C0D"
strokeWidth="1.14286"
/>
<g clipPath="url(#clip0_1_5)">
<path
d="M18 36C27.9411 36 36 27.9411 36 18C36 8.05888 27.9411 0 18 0C8.05888 0 0 8.05888 0 18C0 27.9411 8.05888 36 18 36Z"
fill="#0C0C0D"
/>
<path
d="M18 31.5C25.4558 31.5 31.5 25.4558 31.5 18C31.5 10.5442 25.4558 4.5 18 4.5C10.5442 4.5 4.5 10.5442 4.5 18C4.5 25.4558 10.5442 31.5 18 31.5Z"
fill="white"
/>
<path
d="M18 24.75C21.7279 24.75 24.75 21.7279 24.75 18C24.75 14.2721 21.7279 11.25 18 11.25C14.2721 11.25 11.25 14.2721 11.25 18C11.25 21.7279 14.2721 24.75 18 24.75Z"
fill="#0C0C0D"
/>
<path
d="M36 18C36 8.05888 27.9411 0 18 0C8.05888 0 0 8.05888 0 18C0 27.9411 8.05888 36 18 36C27.9411 36 36 27.9411 36 18Z"
stroke="white"
/>
</g>
<defs>
<clipPath id="clip0_1_5">
<rect width="36" height="36" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import uniqby from 'lodash.uniqby';
import { LoadingSpinner } from '@app/components/loading-spinner';
import { useBitcoinPendingTransactions } from '@app/query/bitcoin/address/transactions-by-address.hooks';
import { useGetBitcoinTransactionsByAddressQuery } from '@app/query/bitcoin/address/transactions-by-address.query';
import { useGetBitcoinTransactionsByAddressesQuery } from '@app/query/bitcoin/address/transactions-by-address.query';
import { useZeroIndexTaprootAddress } from '@app/query/bitcoin/ordinals/use-zero-index-taproot-address';
import { useConfigBitcoinEnabled } from '@app/query/common/remote-config/remote-config.query';
import { useStacksPendingTransactions } from '@app/query/stacks/mempool/mempool.hooks';
import { useGetAccountTransactionsWithTransfersQuery } from '@app/query/stacks/transactions/transactions-with-transfers.query';
@@ -17,7 +20,7 @@ import { TransactionList } from './components/transaction-list/transaction-list'
// TODO: temporary really ugly fix while we address conditional data problem of
// bitcoin sometimes being undefined
function useBitcoinAddress() {
function useNsBitcoinAddress() {
try {
return useCurrentAccountNativeSegwitIndexZeroSigner().address;
} catch (e) {
@@ -25,11 +28,32 @@ function useBitcoinAddress() {
}
}
function useTrBitcoinAddress() {
try {
return useZeroIndexTaprootAddress();
} catch (e) {
return '';
}
}
export function ActivityList() {
const bitcoinAddress = useBitcoinAddress();
const { isInitialLoading: isInitialLoadingBitcoinTransactions, data: bitcoinTransactions } =
useGetBitcoinTransactionsByAddressQuery(bitcoinAddress);
const { data: bitcoinPendingTxs = [] } = useBitcoinPendingTransactions(bitcoinAddress);
const nsBitcoinAddress = useNsBitcoinAddress();
const trBitcoinAddress = useTrBitcoinAddress();
const [
{ isInitialLoading: isInitialLoadingNsBitcoinTransactions, data: nsBitcoinTransactions = [] },
{ isInitialLoading: isInitialLoadingTrBitcoinTransactions, data: trBitcoinTransactions = [] },
] = useGetBitcoinTransactionsByAddressesQuery([nsBitcoinAddress, trBitcoinAddress]);
const [{ data: nsPendingTxs = [] }, { data: trPendingTxs = [] }] = useBitcoinPendingTransactions([
nsBitcoinAddress,
trBitcoinAddress,
]);
const bitcoinPendingTxs = useMemo(
() => [...nsPendingTxs, ...trPendingTxs],
[nsPendingTxs, trPendingTxs]
);
const {
isInitialLoading: isInitialLoadingStacksTransactions,
data: stacksTransactionsWithTransfers,
@@ -42,14 +66,16 @@ export function ActivityList() {
const isBitcoinEnabled = useConfigBitcoinEnabled();
const isInitialLoading =
isInitialLoadingBitcoinTransactions ||
isInitialLoadingNsBitcoinTransactions ||
isInitialLoadingTrBitcoinTransactions ||
isInitialLoadingStacksTransactions ||
isInitialLoadingStacksPendingTransactions;
const transactionListBitcoinTxs = useMemo(
() => convertBitcoinTxsToListType(bitcoinTransactions),
[bitcoinTransactions]
);
const transactionListBitcoinTxs = useMemo(() => {
return convertBitcoinTxsToListType(
uniqby([...nsBitcoinTransactions, ...trBitcoinTransactions], 'txid')
);
}, [nsBitcoinTransactions, trBitcoinTransactions]);
const pendingTransactionIds = stacksPendingTransactions.map(tx => tx.tx_id);
const transactionListStacksTxs = useMemo(
@@ -85,6 +111,7 @@ export function ActivityList() {
<TransactionList
bitcoinTxs={isBitcoinEnabled ? transactionListBitcoinTxs : []}
stacksTxs={transactionListStacksTxs}
currentBitcoinAddress={nsBitcoinAddress}
/>
)}
</>

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
export function useTransactionListRender({
currentBitcoinAddress,
}: {
currentBitcoinAddress: string;
}) {
const [visibleTxsNum, setVisibleTxsNum] = useState(10);
const { ref: intersectionSentinel, inView } = useInView({
rootMargin: '0% 0% 20% 0%',
});
useEffect(() => {
if (inView) {
setVisibleTxsNum(visibleTxsNum + 10);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView]);
useEffect(() => {
setVisibleTxsNum(10);
}, [currentBitcoinAddress]);
return {
visibleTxsNum,
intersectionSentinel,
};
}

View File

@@ -1,5 +1,8 @@
import { useMemo } from 'react';
import { Box } from '@stacks/ui';
import { useTransactionListRender } from './hooks/use-transaction-list-render';
import { TransactionListItem } from './transaction-list-item';
import { TransactionListLayout } from './transaction-list.layout';
import { TransactionListBitcoinTx, TransactionListStacksTx } from './transaction-list.model';
@@ -9,23 +12,50 @@ import { TransactionsByDateLayout } from './transactions-by-date.layout';
interface TransactionListProps {
bitcoinTxs: TransactionListBitcoinTx[];
stacksTxs: TransactionListStacksTx[];
currentBitcoinAddress: string;
}
export function TransactionList({ bitcoinTxs, stacksTxs }: TransactionListProps) {
export function TransactionList({
bitcoinTxs,
stacksTxs,
currentBitcoinAddress,
}: TransactionListProps) {
const { intersectionSentinel, visibleTxsNum } = useTransactionListRender({
currentBitcoinAddress,
});
const txsGroupedByDate = useMemo(
() =>
bitcoinTxs.length || stacksTxs.length ? createTxDateFormatList(bitcoinTxs, stacksTxs) : [],
[bitcoinTxs, stacksTxs]
);
const groupedByDateTxsLength = useMemo(() => {
return txsGroupedByDate.reduce((acc: Record<string, number>, item, index) => {
acc[index] = item.txs.length + (acc[index - 1] || 0);
return acc;
}, {});
}, [txsGroupedByDate]);
return (
<TransactionListLayout>
{txsGroupedByDate.map(({ date, displayDate, txs }) => (
<TransactionsByDateLayout date={date} displayDate={displayDate} key={date}>
{txs.map(tx => (
<TransactionListItem key={getTransactionId(tx)} tx={tx} />
))}
</TransactionsByDateLayout>
))}
{txsGroupedByDate.map(({ date, displayDate, txs }, dateIndex) => {
const prevVal = groupedByDateTxsLength[dateIndex - 1] || 0;
// hide dates with no visible txs
if (prevVal > visibleTxsNum) {
return null;
}
return (
<TransactionsByDateLayout date={date} displayDate={displayDate} key={date}>
{txs.map((tx, txIndex) => {
// hide txs that are not visible
if (prevVal + txIndex > visibleTxsNum) return null;
return <TransactionListItem key={getTransactionId(tx)} tx={tx} />;
})}
</TransactionsByDateLayout>
);
})}
<Box ref={intersectionSentinel} />
</TransactionListLayout>
);
}

View File

@@ -43,6 +43,7 @@ export function useGenerateSignedOrdinalTx(trInput: TaprootUtxo) {
txid: trInput.txid,
index: trInput.vout,
tapInternalKey: trSigner.payment.tapInternalKey,
sequence: 0,
witnessUtxo: {
script: trSigner.payment.script,
amount: BigInt(trInput.value),
@@ -54,6 +55,7 @@ export function useGenerateSignedOrdinalTx(trInput: TaprootUtxo) {
tx.addInput({
txid: input.txid,
index: input.vout,
sequence: 0,
witnessUtxo: {
amount: BigInt(input.value),
script: nativeSegwitSigner.payment.script,

View File

@@ -6,7 +6,10 @@ import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model
import { sumNumbers } from '@app/common/math/helpers';
import { UtxoResponseItem } from '../bitcoin-client';
import { useGetBitcoinTransactionsByAddressQuery } from './transactions-by-address.query';
import {
useGetBitcoinTransactionsByAddressQuery,
useGetBitcoinTransactionsByAddressesQuery,
} from './transactions-by-address.query';
import { useAllSpendableNativeSegwitUtxos } from './utxos-by-address.hooks';
function useFilterAddressPendingTransactions() {
@@ -15,10 +18,10 @@ function useFilterAddressPendingTransactions() {
}, []);
}
export function useBitcoinPendingTransactions(address: string) {
export function useBitcoinPendingTransactions(addresses: string[]) {
const filterPendingTransactions = useFilterAddressPendingTransactions();
return useGetBitcoinTransactionsByAddressQuery(address, {
return useGetBitcoinTransactionsByAddressesQuery(addresses, {
select(txs) {
return filterPendingTransactions(txs);
},

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
@@ -23,3 +23,22 @@ export function useGetBitcoinTransactionsByAddressQuery<T extends unknown = Bitc
...options,
});
}
export function useGetBitcoinTransactionsByAddressesQuery<T extends unknown = BitcoinTx[]>(
addresses: string[],
options?: AppUseQueryConfig<BitcoinTx[], T>
) {
const client = useBitcoinClient();
return useQueries({
queries: addresses.map(address => {
return {
enabled: !!address,
queryKey: ['btc-txs-by-addresses', address],
queryFn: () => client.addressApi.getTransactionsByAddress(address),
...queryOptions,
...options,
};
}),
});
}

View File

@@ -6,7 +6,7 @@ import {
import { useGetInscriptionQuery } from './inscription.query';
function createInfoUrl(id: string) {
export function createInscriptionInfoUrl(id: string) {
return `https://ordinals.hiro.so/inscription/${id}`;
}
@@ -14,7 +14,7 @@ export function convertInscriptionToSupportedInscriptionType(inscription: Inscri
const title = `Inscription ${inscription.number}`;
return whenInscriptionType<SupportedInscription>(inscription.content_type, {
image: () => ({
infoUrl: createInfoUrl(inscription.id),
infoUrl: createInscriptionInfoUrl(inscription.id),
src: `https://api.hiro.so/ordinals/v1/inscriptions/${inscription.id}/content`,
type: 'image',
title,
@@ -22,13 +22,13 @@ export function convertInscriptionToSupportedInscriptionType(inscription: Inscri
}),
text: () => ({
contentSrc: `https://api.hiro.so/ordinals/v1/inscriptions/${inscription.id}/content`,
infoUrl: createInfoUrl(inscription.id),
infoUrl: createInscriptionInfoUrl(inscription.id),
type: 'text',
title,
...inscription,
}),
other: () => ({
infoUrl: createInfoUrl(inscription.id),
infoUrl: createInscriptionInfoUrl(inscription.id),
type: 'other',
title,
...inscription,

View File

@@ -0,0 +1,17 @@
import { BitcoinTx } from '@shared/models/transactions/bitcoin-transaction.model';
import { convertInscriptionToSupportedInscriptionType } from './inscription.hooks';
import { useGetInscriptionByParamQuery } from './use-inscription-by-param.query';
export function useInscriptionByOutput(transaction: BitcoinTx) {
const inputsLength = transaction.vin.length;
const index = inputsLength === 1 ? 0 : inputsLength - 2;
return useGetInscriptionByParamQuery(`output=${transaction.txid}:${index}`, {
select(data) {
const inscription = data.results[0];
if (!inscription) return;
return convertInscriptionToSupportedInscriptionType(inscription);
},
});
}

View File

@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { Paginated } from '@shared/models/api-types';
import { Inscription } from '@shared/models/inscription.model';
import { AppUseQueryConfig } from '@app/query/query-config';
const inscriptionQueryOptions = {
staleTime: Infinity,
cacheTime: Infinity,
} as const;
function fetchInscriptionByParam() {
return async (param: string) => {
const res = await fetch(`https://api.hiro.so/ordinals/v1/inscriptions?${param}`);
if (!res.ok) throw new Error('Error retrieving inscription metadata');
const data = await res.json();
return data as Paginated<Inscription[]>;
};
}
type FetchInscriptionResp = Awaited<ReturnType<ReturnType<typeof fetchInscriptionByParam>>>;
export function useGetInscriptionByParamQuery<T extends unknown = FetchInscriptionResp>(
param: string,
options?: AppUseQueryConfig<FetchInscriptionResp, T>
) {
return useQuery({
enabled: !!param,
queryKey: ['inscription-by-param', param],
queryFn: () => fetchInscriptionByParam()(param),
...inscriptionQueryOptions,
...options,
});
}

View File

@@ -6245,6 +6245,13 @@
dependencies:
"@types/lodash" "*"
"@types/lodash.uniqby@4.7.7":
version "4.7.7"
resolved "https://registry.yarnpkg.com/@types/lodash.uniqby/-/lodash.uniqby-4.7.7.tgz#48dbb652c41cc8fb30aa61a44174368081835ab5"
integrity sha512-sv2g6vkCIvEUsK5/Vq17haoZaisfj2EWW8mP7QWlnKi6dByoNmeuHDDXHR7sabuDqwO4gvU7ModIL22MmnOocg==
dependencies:
"@types/lodash" "*"
"@types/lodash@*", "@types/lodash@^4.14.178", "@types/lodash@^4.14.191":
version "4.14.194"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76"
@@ -13738,6 +13745,11 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash.uniqby@4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302"
integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==
lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
@@ -15657,6 +15669,11 @@ react-icons@^4.3.1, react-icons@^4.7.1:
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.8.0.tgz#621e900caa23b912f737e41be57f27f6b2bff445"
integrity sha512-N6+kOLcihDiAnj5Czu637waJqSnwlMNROzVZMhfX68V/9bu9qHaMIJC4UdozWoOk57gahFCNHwVvWzm0MTzRjg==
react-intersection-observer@9.5.2:
version "9.5.2"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz#f68363a1ff292323c0808201b58134307a1626d0"
integrity sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q==
react-is@16.9.0:
version "16.9.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"