Files

920 lines
31 KiB
TypeScript

'use client';
import {
formatDate,
formatLocalizedTimeFromSeconds,
formatNumber,
formatPercentage,
} from '@/lib/locale-client';
import {
ButtonDataTestId,
NodeCardDataTestId,
StakedNodeDataTestId,
} from '@/testing/data-test-ids';
import { NODE_STATE, Stake } from '@session/sent-staking-js/client';
import { StatusIndicator, statusVariants } from '@session/ui/components/StatusIndicator';
import { ArrowDownIcon } from '@session/ui/icons/ArrowDownIcon';
import { SpannerAndScrewdriverIcon } from '@session/ui/icons/SpannerAndScrewdriverIcon';
import { cn } from '@session/ui/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { useTranslations } from 'next-intl';
import { forwardRef, type HTMLAttributes, ReactNode, useMemo, useState } from 'react';
import { NodeCard, NodeCardText, NodeCardTitle, NodeContributorList } from './NodeCard';
import { PubKey } from '@session/ui/components/PubKey';
import { areHexesEqual } from '@session/util-crypto/string';
import { Button } from '@session/ui/ui/button';
import { NodeRequestExitButton } from '@/components/StakedNode/NodeRequestExitButton';
import { Tooltip } from '@session/ui/ui/tooltip';
import { SESSION_NODE, SESSION_NODE_TIME, SESSION_NODE_TIME_STATIC, URL } from '@/lib/constants';
import { useChain } from '@session/contracts/hooks/useChain';
import { NodeExitButton } from '@/components/StakedNode/NodeExitButton';
import { NodeExitButtonDialog } from '@/components/StakedNode/NodeExitButtonDialog';
import { externalLink } from '@/lib/locale-defaults';
import { TextSeparator } from '@session/ui/components/Separator';
import useRelativeTime from '@/hooks/useRelativeTime';
import { getDateFromUnixTimestampSeconds } from '@session/util-js/date';
import { FEATURE_FLAG } from '@/lib/feature-flags';
import { useFeatureFlag } from '@/lib/feature-flags-client';
import { formatSENTNumber } from '@session/contracts/hooks/SENT';
import { ActionModuleDivider } from '@/components/ActionModule';
import { Address } from 'viem';
import { useWallet } from '@session/wallet/hooks/wallet-hooks';
type StakeInContract = Stake & {
contract_id: NonNullable<Stake['contract_id']>;
staked_balance: NonNullable<Stake['staked_balance']>;
};
/** The stake is awaiting contributors. */
type AwaitingContributorsNode = Stake & {
state: NODE_STATE.AWAITING_CONTRIBUTORS;
last_uptime_proof: null;
};
/** The stake has been cancelled. */
type CancelledNode = AwaitingContributorsNode & { state: NODE_STATE.CANCELLED };
/** The stake is a running node. */
type RunningNode = StakeInContract & {
state: NODE_STATE.RUNNING;
last_uptime_proof: NonNullable<Stake['last_uptime_proof']>;
};
/** The stake has been decommissioned. */
type DecommissionedNode = StakeInContract & {
state: NODE_STATE.DECOMMISSIONED;
};
/** The stake has been deregistered. */
type DeregisteredNode = Stake & {
state: NODE_STATE.DEREGISTERED;
deregistration_unlock_height: NonNullable<Stake['deregistration_unlock_height']>;
liquidation_height: NonNullable<Stake['liquidation_height']>;
};
/** The stake has been requested to exit the smart contract.*/
type NodeRequestingExit = (RunningNode | DecommissionedNode) & {
requested_unlock_height: NonNullable<Stake['requested_unlock_height']>;
liquidation_height: NonNullable<Stake['liquidation_height']>;
};
/** The stake is awaiting liquidation. */
type AwaitingExitNode = NodeRequestingExit & {
state: NODE_STATE.AWAITING_EXIT;
liquidation_height: NonNullable<Stake['liquidation_height']>;
};
/** The stake has exited the smart contract gracefully. */
type ExitedNode = NodeRequestingExit & {
state: NODE_STATE.EXITED;
exited: true;
};
/**
* Checks if a node is being deregistered.
* @param node - The node to check.
* @returns `true` if the node is being deregistered, `false` otherwise.
*/
const isBeingDeregistered = (node: Stake): node is DecommissionedNode =>
node.state === NODE_STATE.DECOMMISSIONED;
const hasDeregistrationUnlockHeight = (node: Stake): node is DeregisteredNode =>
!!('deregistration_unlock_height' in node && node.deregistration_unlock_height);
const hasRequestedUnlockHeight = (node: Stake): node is NodeRequestingExit =>
!!('requested_unlock_height' in node && node.requested_unlock_height);
/**
* Checks if a given stake has exited the smart contract.
* @see {@link ExitedNode}
* @see {@link DeregisteredNode}
* @param stake - The stake to check.
*
* @note The `contract_id` check here is for and edge case. If a node has exited the smart contract
* and the network hasn't confirmed the event yet then the `contract_id` is `null`. BUT this should
* not happen as the node info should be stored in the database and the backend should use that data,
* which includes the contract_id. So it's a technically possible edge case but not likely to happen
* as long as the backend is doing its job well. BUT if people host their own backend there is no
* guarantee the database has the node in it so the `contract_id` might be `null`.
*/
const hasExited = (stake: Stake): stake is ExitedNode =>
stake.exited || ('contract_id' in stake && stake.contract_id === null);
/**
* Checks if a given stake is requesting to exit the smart contract.
* @see {@link NodeRequestingExit}
* @param stake - The stake to check.
* @param blockHeight - The current block height.
*/
const isRequestingToExit = (stake: Stake, blockHeight: number): stake is NodeRequestingExit =>
!hasExited(stake) &&
hasRequestedUnlockHeight(stake) &&
stake.requested_unlock_height >= blockHeight;
/**
* Checks if a given stake is ready to exit the smart contract.
* @see {@link NodeRequestingExit}
* @param stake - The stake to check.
* @param blockHeight - The current block height.
*/
const isReadyToExit = (stake: Stake, blockHeight: number): stake is NodeRequestingExit =>
!hasExited(stake) &&
((hasRequestedUnlockHeight(stake) && stake.requested_unlock_height <= blockHeight) ||
(hasDeregistrationUnlockHeight(stake) && stake.deregistration_unlock_height <= blockHeight));
// const isNodePastLiquidationBlock = (node: Stake, blockHeight: number): boolean =>
// !!(
// 'liquidation_height' in node &&
// node.liquidation_height &&
// node.liquidation_height <= blockHeight
// );
/**
* Checks if a given node is operated by a specific user.
* @param node - The session node to check.
* @param address - The address of the user to compare with.
* @returns `true` if the node is operated by the specified user, `false` otherwise.
*/
const isNodeOperator = (node: Stake, address: string): boolean =>
areHexesEqual(node.operator_address, address);
/**
* Checks if a given contributor address is a contributor of a session node.
*
* @param node - The session node to check.
* @param contributorAddress - The address of the contributor to check.
* @returns `true` if the contributor address is a contributor of the session node, `false` otherwise.
*/
// const isNodeContributor = (node: StakedNode, contributorAddress: string): boolean =>
// node.contributors.some(({ address }) => areHexesEqual(address, contributorAddress));
function getNodeStatus(state: NODE_STATE): VariantProps<typeof statusVariants>['status'] {
switch (state) {
case NODE_STATE.RUNNING:
return 'green';
case NODE_STATE.AWAITING_CONTRIBUTORS:
return 'blue';
case NODE_STATE.DECOMMISSIONED:
return 'yellow';
case NODE_STATE.CANCELLED:
case NODE_STATE.DEREGISTERED:
return 'red';
case NODE_STATE.EXITED:
default:
return 'grey';
}
}
const blocksInMs = (blocks: number) => blocks * SESSION_NODE.MS_PER_BLOCK;
const msInBlocks = (ms: number) => Math.floor(ms / SESSION_NODE.MS_PER_BLOCK);
class BlockTimeManager {
private readonly networkTime: number;
private readonly currentBlock: number;
constructor(networkTime: number, currentBlock: number) {
this.networkTime = networkTime;
this.currentBlock = currentBlock;
}
getDateOfBlock(targetBlock: number) {
return new Date(this.networkTime * 1000 + blocksInMs(targetBlock - this.currentBlock));
}
}
type ToggleCardExpansionButtonProps = HTMLAttributes<HTMLLabelElement> & {
htmlFor: string;
};
const ToggleCardExpansionButton = forwardRef<HTMLLabelElement, ToggleCardExpansionButtonProps>(
({ className, ...props }, ref) => {
const [expanded, setExpanded] = useState(false);
const dictionary = useTranslations('nodeCard.staked');
return (
<label
ref={ref}
role="button"
onClick={() => setExpanded((prev) => !prev)}
aria-label={expanded ? dictionary(`ariaCollapse`) : dictionary(`ariaExpand`)}
data-testid={
expanded ? StakedNodeDataTestId.Collapse_Button : StakedNodeDataTestId.Expand_Button
}
className={cn(
'ml-auto flex w-max cursor-pointer select-none items-center align-middle peer-checked:[&>svg]:rotate-180',
className
)}
{...props}
>
<span className="text-gradient-white hidden font-medium lg:inline-block">
{expanded ? dictionary('labelCollapse') : dictionary('labelExpand')}
</span>
<ArrowDownIcon
className={cn(
'fill-session-text stroke-session-text ml-1 h-4 w-4 transform transition-all duration-300 ease-in-out motion-reduce:transition-none'
)}
/>
</label>
);
}
);
type NodeNotificationProps = HTMLAttributes<HTMLSpanElement> & {
level?: 'info' | 'warning' | 'error';
};
const NodeNotification = forwardRef<HTMLSpanElement, NodeNotificationProps>(
({ className, children, level, ...props }, ref) => (
<span
ref={ref}
className={cn(
'flex w-3/4 flex-row gap-2 text-xs font-normal sm:w-max md:text-base',
level === 'warning'
? 'text-warning'
: level === 'error'
? 'text-destructive'
: 'text-session-text',
className
)}
{...props}
>
<span>•</span>
{children}
</span>
)
);
const NodeOperatorIndicator = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const dictionary = useTranslations('nodeCard.staked');
return (
<>
<Tooltip tooltipContent={dictionary('operatorTooltip')}>
<div
ref={ref}
className={cn(
'text-session-green flex flex-row items-center gap-1 align-middle text-sm font-normal md:text-base',
className
)}
{...props}
>
<SpannerAndScrewdriverIcon className="fill-session-green h-3.5 w-3.5" />
{dictionary('operator')}
</div>
</Tooltip>
</>
);
}
);
/**
* Checks if a given date is in the past or `soon`
* @see {@link SESSION_NODE_TIME_STATIC.SOON_TIME}
* @param date - The date to check.
* @returns `true` if the date is in the past or `soon`, `false` otherwise.
*/
const isDateSoonOrPast = (date: Date | null): boolean =>
!!(date && Date.now() > date.getTime() - SESSION_NODE_TIME_STATIC.SOON_TIME);
const ReadyForExitNotification = ({
date,
timeString,
className,
}: {
date: Date | null;
timeString: string | null;
className?: string;
}) => {
const dictionary = useTranslations('nodeCard.staked');
const dictionaryGeneral = useTranslations('general');
const notFoundString = dictionaryGeneral('notFound');
const soonString = dictionaryGeneral('soon');
const isLiquidationSoon = useMemo(() => isDateSoonOrPast(date), [date]);
const relativeTime = useMemo(
() => (!isLiquidationSoon ? timeString : soonString) ?? notFoundString,
[isLiquidationSoon, timeString, soonString, notFoundString]
);
return (
<Tooltip
tooltipContent={dictionary.rich(
isLiquidationSoon ? 'exitTimerDescriptionNow' : 'exitTimerDescription',
{
relativeTime,
link: externalLink(URL.NODE_LIQUIDATION_LEARN_MORE),
}
)}
>
<NodeNotification level={isLiquidationSoon ? 'error' : 'warning'} className={className}>
{isLiquidationSoon
? dictionary('exitTimerNotificationNow')
: dictionary('exitTimerNotification', { relativeTime })}
</NodeNotification>
</Tooltip>
);
};
const ExitUnlockTimerNotification = ({
date,
timeString,
className,
}: {
date: Date | null;
timeString: string | null;
className?: string;
}) => {
const dictionary = useTranslations('nodeCard.staked');
const dictionaryGeneral = useTranslations('general');
const notFoundString = dictionaryGeneral('notFound');
const soonString = dictionaryGeneral('soon');
const isExitableSoon = useMemo(() => isDateSoonOrPast(date), [date]);
const relativeTime = useMemo(
() => (!isExitableSoon ? timeString : soonString) ?? notFoundString,
[isExitableSoon, timeString, soonString, notFoundString]
);
return (
<Tooltip
tooltipContent={dictionary('exitUnlockTimerDescription', {
relativeTime,
date: date ? formatDate(date, { dateStyle: 'full', timeStyle: 'short' }) : notFoundString,
})}
>
<NodeNotification level="warning" className={className}>
{dictionary('exitUnlockTimerNotification', { relativeTime })}
</NodeNotification>
</Tooltip>
);
};
const DeregisteringNotification = ({
date,
timeString,
}: {
date: Date | null;
timeString: string | null;
}) => {
const chain = useChain();
const dictionary = useTranslations('nodeCard.staked');
const generalDictionary = useTranslations('general');
const notFoundString = generalDictionary('notFound');
const soonString = generalDictionary('soon');
const isDeregistrationSoon = isDateSoonOrPast(date);
const relativeTime = useMemo(
() => (!isDeregistrationSoon ? timeString : soonString) ?? notFoundString,
[isDeregistrationSoon, timeString, soonString, notFoundString]
);
return (
<Tooltip
tooltipContent={dictionary('deregistrationTimerDescription', {
lockedStakeTime: formatLocalizedTimeFromSeconds(
SESSION_NODE_TIME(chain).DEREGISTRATION_LOCKED_STAKE_SECONDS,
{ unit: 'day' }
),
relativeTime,
date: date ? formatDate(date, { dateStyle: 'full', timeStyle: 'short' }) : notFoundString,
})}
>
<NodeNotification level="error">
{dictionary('deregistrationTimerNotification', { relativeTime })}
</NodeNotification>
</Tooltip>
);
};
type NodeSummaryProps = {
node: Stake;
blockHeight: number;
deregistrationDate: Date | null;
deregistrationTime: string | null;
requestedUnlockDate: Date | null;
requestedUnlockTime: string | null;
deregistrationUnlockDate: Date | null;
deregistrationUnlockTime: string | null;
liquidationDate: Date | null;
liquidationTime: string | null;
showAllTimers?: boolean;
};
const NodeSummary = ({
node,
blockHeight,
deregistrationDate,
deregistrationTime,
requestedUnlockDate,
requestedUnlockTime,
deregistrationUnlockDate,
deregistrationUnlockTime,
liquidationDate,
liquidationTime,
showAllTimers,
}: NodeSummaryProps) => {
const allTimers = [];
if (isReadyToExit(node, blockHeight)) {
const readyToExitTimer = (
<>
<NodeContributorList
contributors={node.contributors}
data-testid={StakedNodeDataTestId.Contributor_List}
/>
<ReadyForExitNotification date={liquidationDate} timeString={liquidationTime} />
</>
);
if (showAllTimers) {
allTimers.push(readyToExitTimer);
} else {
return readyToExitTimer;
}
}
if (hasDeregistrationUnlockHeight(node)) {
const deregisteringTimer = (
<>
<NodeContributorList
contributors={node.contributors}
data-testid={StakedNodeDataTestId.Contributor_List}
/>
<ExitUnlockTimerNotification
date={deregistrationUnlockDate}
timeString={deregistrationUnlockTime}
/>
</>
);
if (showAllTimers) {
allTimers.push(deregisteringTimer);
} else {
return deregisteringTimer;
}
}
if (isBeingDeregistered(node)) {
const beingDeregisteredTimers = (
<DeregisteringNotification date={deregistrationDate} timeString={deregistrationTime} />
);
if (showAllTimers) {
allTimers.push(beingDeregisteredTimers);
} else {
return beingDeregisteredTimers;
}
}
if (isRequestingToExit(node, blockHeight)) {
const requestingExitTimer = (
<>
<NodeContributorList
contributors={node.contributors}
data-testid={StakedNodeDataTestId.Contributor_List}
/>
{
<ExitUnlockTimerNotification
date={requestedUnlockDate}
timeString={requestedUnlockTime}
/>
}
</>
);
if (showAllTimers) {
allTimers.push(requestingExitTimer);
} else {
return requestingExitTimer;
}
}
if (
(node.state === NODE_STATE.AWAITING_CONTRIBUTORS || node.state === NODE_STATE.RUNNING) &&
!showAllTimers
) {
return (
<NodeContributorList
contributors={node.contributors}
showEmptySlots={node.state === NODE_STATE.AWAITING_CONTRIBUTORS}
data-testid={StakedNodeDataTestId.Contributor_List}
/>
);
}
if (showAllTimers) {
return allTimers;
} else {
return null;
}
};
const collapsableContentVariants = cva(
'h-full max-h-0 select-none gap-1 overflow-y-hidden transition-all duration-300 ease-in-out peer-checked:select-auto motion-reduce:transition-none',
{
variants: {
size: {
xs: 'text-xs md:text-xs peer-checked:max-h-4',
base: cn('text-sm peer-checked:max-h-5', 'md:text-base md:peer-checked:max-h-6'),
buttonMd: cn('peer-checked:max-h-10'),
},
width: {
'w-full': 'w-full',
'w-max': 'w-max',
},
},
defaultVariants: {
size: 'base',
width: 'w-full',
},
}
);
type CollapsableContentProps = HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof collapsableContentVariants>;
export const CollapsableContent = forwardRef<HTMLSpanElement, CollapsableContentProps>(
({ className, size, width, ...props }, ref) => (
<NodeCardText
ref={ref}
className={cn(collapsableContentVariants({ size, width, className }))}
{...props}
/>
)
);
const RowLabel = ({ children }: { children: ReactNode }) => (
<span className="font-semibold">{children} </span>
);
export const CollapsableButton = forwardRef<
HTMLButtonElement,
HTMLAttributes<HTMLButtonElement> & {
ariaLabel: string;
dataTestId: ButtonDataTestId;
disabled?: boolean;
mobileChildren?: ReactNode;
}
>(({ ariaLabel, dataTestId, disabled, children, ...props }, ref) => (
<CollapsableContent
className="bottom-4 right-6 flex w-max items-end min-[500px]:absolute"
size="buttonMd"
>
<Button
data-testid={dataTestId}
aria-label={ariaLabel}
disabled={disabled}
rounded="md"
size="md"
variant="destructive-outline"
className="uppercase"
ref={ref}
{...props}
>
{children}
</Button>
</CollapsableContent>
));
const useNodeDates = (node: Stake, currentBlock: number, networkTime: number) => {
const blockTime = new BlockTimeManager(networkTime, currentBlock);
const {
last_reward_block_height: lastRewardBlock,
last_uptime_proof: lastUptimeProofSeconds,
earned_downtime_blocks: earnedDowntimeBlocks,
requested_unlock_height: requestedUnlockBlock,
deregistration_unlock_height: deregistrationUnlockBlock,
liquidation_height: liquidationBlock,
} = node;
return useMemo(() => {
const lastUptimeDate = lastUptimeProofSeconds
? getDateFromUnixTimestampSeconds(lastUptimeProofSeconds)
: null;
const deregistrationDate = earnedDowntimeBlocks
? blockTime.getDateOfBlock(currentBlock + earnedDowntimeBlocks)
: null;
const lastRewardDate = lastRewardBlock ? blockTime.getDateOfBlock(lastRewardBlock) : null;
const requestedUnlockDate = requestedUnlockBlock
? blockTime.getDateOfBlock(requestedUnlockBlock)
: null;
const deregistrationUnlockDate = deregistrationUnlockBlock
? blockTime.getDateOfBlock(deregistrationUnlockBlock)
: null;
const liquidationDate = liquidationBlock ? blockTime.getDateOfBlock(liquidationBlock) : null;
return {
lastUptimeDate,
deregistrationDate,
lastRewardDate,
requestedUnlockDate,
deregistrationUnlockDate,
liquidationDate,
};
}, [
blockTime,
networkTime,
currentBlock,
earnedDowntimeBlocks,
lastUptimeProofSeconds,
lastRewardBlock,
requestedUnlockBlock,
deregistrationUnlockBlock,
liquidationBlock,
]);
};
/**
* Generates a unique identifier for a stake. This is used to identify the stake in the UI for the
* list key and for creating a collapse toggle key.
* @param stake - The stake to generate an identifier for.
*
* @note `contract_id` is a unique identifier for the stake in the smart contract.
* @note stakes that have exited the smart contract will have a null contract_id.
* @note stakes can have the same `service_node_pubkey` if they are for the same node. A node can be
* staked to, exited, then re-staked.
* @note `last_uptime_proof` is in no way unique. But it's impossible for a stake with the same
* `service_node_pubkey` to have the same `last_uptime_proof`, as by definition the two stakes are
* for the same node and can't exist in the same time.
*
* @returns A unique identifier for the stake.
*/
export const generateStakeId = (stake: Stake) =>
`stake-${stake.contract_id}-${stake.service_node_pubkey}-${stake.last_uptime_proof}`;
const StakedNodeCard = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {
node: Stake;
targetWalletAddress?: Address;
blockHeight: number;
networkTime: number;
uniqueId?: string;
hideButton?: boolean;
}
>(
(
{
className,
node,
blockHeight,
networkTime,
hideButton,
uniqueId,
targetWalletAddress,
...props
},
ref
) => {
const dictionary = useTranslations('nodeCard.staked');
const generalDictionary = useTranslations('general');
const generalNodeDictionary = useTranslations('sessionNodes.general');
const stakingNodeDictionary = useTranslations('sessionNodes.staking');
const titleFormat = useTranslations('modules.title');
const notFoundString = generalDictionary('notFound');
const { address: connectedAddress } = useWallet();
const walletAddress = useMemo(
() => targetWalletAddress ?? connectedAddress,
[targetWalletAddress, connectedAddress]
);
const toggleId = useMemo(() => `toggle-${uniqueId ?? generateStakeId(node)}`, [uniqueId, node]);
const {
state,
service_node_pubkey: pubKey,
operator_fee: operatorFee,
operator_address: operatorAddress,
contributors,
staked_balance: stakedBalance,
last_reward_block_height: lastRewardBlock,
last_uptime_proof: lastUptimeProofSeconds,
} = node;
const formattedStakeBalance = useMemo(() => formatSENTNumber(stakedBalance), [stakedBalance]);
const showAllTimers = useFeatureFlag(FEATURE_FLAG.SHOW_ALL_TIMERS);
const showRawNodeData = useFeatureFlag(FEATURE_FLAG.SHOW_NODE_RAW_DATA);
const {
lastUptimeDate,
deregistrationDate,
lastRewardDate,
requestedUnlockDate,
deregistrationUnlockDate,
liquidationDate,
} = useNodeDates(node, blockHeight, networkTime);
const lastRewardTime = useRelativeTime(lastRewardDate, { addSuffix: true });
const deregistrationTime = useRelativeTime(deregistrationDate, { addSuffix: true });
const lastUptimeTime = useRelativeTime(lastUptimeDate, { addSuffix: true });
const requestedUnlockTime = useRelativeTime(requestedUnlockDate, { addSuffix: true });
const deregistrationUnlockTime = useRelativeTime(deregistrationUnlockDate, { addSuffix: true });
const liquidationTime = useRelativeTime(liquidationDate, { addSuffix: true });
const isSoloNode = contributors.length === 1;
return (
<NodeCard
ref={ref}
{...props}
className={cn(
'relative flex flex-row flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden pb-4 align-middle',
className
)}
data-testid={NodeCardDataTestId.Staked_Node}
>
<input id={toggleId} type="checkbox" className="peer hidden appearance-none" />
<StatusIndicator
status={getNodeStatus(state)}
data-testid={StakedNodeDataTestId.Indicator}
/>
<NodeCardTitle data-testid={StakedNodeDataTestId.Title}>{state}</NodeCardTitle>
<NodeSummary
node={node}
blockHeight={blockHeight}
deregistrationDate={deregistrationDate}
deregistrationTime={deregistrationTime}
deregistrationUnlockDate={deregistrationUnlockDate}
deregistrationUnlockTime={deregistrationUnlockTime}
liquidationDate={liquidationDate}
liquidationTime={liquidationTime}
requestedUnlockTime={requestedUnlockTime}
requestedUnlockDate={requestedUnlockDate}
/>
<ToggleCardExpansionButton htmlFor={toggleId} />
{showAllTimers ? (
<CollapsableContent size="xs">
<NodeSummary
node={node}
blockHeight={blockHeight}
deregistrationDate={deregistrationDate}
deregistrationTime={deregistrationTime}
deregistrationUnlockDate={deregistrationUnlockDate}
deregistrationUnlockTime={deregistrationUnlockTime}
liquidationDate={liquidationDate}
liquidationTime={liquidationTime}
requestedUnlockTime={requestedUnlockTime}
requestedUnlockDate={requestedUnlockDate}
showAllTimers={showAllTimers}
/>
</CollapsableContent>
) : null}
{isBeingDeregistered(node) && isRequestingToExit(node, blockHeight) ? (
<CollapsableContent className="text-warning" size="xs">
<ExitUnlockTimerNotification
date={requestedUnlockDate}
timeString={requestedUnlockTime}
className="md:text-xs"
/>
</CollapsableContent>
) : null}
{state !== NODE_STATE.RUNNING ? (
<CollapsableContent size="xs">
<Tooltip
tooltipContent={dictionary('lastRewardDescription', {
blockNumber: lastRewardBlock ? formatNumber(lastRewardBlock) : notFoundString,
date: lastRewardDate
? formatDate(lastRewardDate, { dateStyle: 'full', timeStyle: 'short' })
: notFoundString,
})}
>
<span className="text-gray-lightest font-normal">
{dictionary('lastReward', {
relativeTime: lastRewardTime ?? notFoundString,
})}
</span>
</Tooltip>
</CollapsableContent>
) : null}
{lastUptimeProofSeconds || state === NODE_STATE.RUNNING ? (
<CollapsableContent size="xs">
<Tooltip
tooltipContent={dictionary('lastUptimeDescription', {
blockNumber: lastUptimeProofSeconds
? formatNumber(
blockHeight - msInBlocks(Date.now() - lastUptimeProofSeconds * 1000)
)
: notFoundString,
date: lastUptimeDate
? formatDate(lastUptimeDate, { dateStyle: 'full', timeStyle: 'short' })
: notFoundString,
})}
>
<span className="text-gray-lightest font-normal">
{dictionary('lastUptime', { relativeTime: lastUptimeTime ?? notFoundString })}
</span>
</Tooltip>
</CollapsableContent>
) : null}
{/** NOTE - ensure any changes here still work with the pubkey component */}
<NodeCardText className="flex w-full flex-row flex-wrap gap-1 peer-checked:mt-1 peer-checked:[&>.separator]:opacity-0 md:peer-checked:[&>.separator]:opacity-100 peer-checked:[&>span>span>button]:opacity-100 peer-checked:[&>span>span>div]:block peer-checked:[&>span>span>span]:hidden">
{walletAddress && isNodeOperator(node, walletAddress) ? (
<>
<NodeOperatorIndicator />
<TextSeparator className="separator mx-1 font-medium" />{' '}
</>
) : null}
<span className="inline-flex flex-nowrap gap-1">
<RowLabel>
{titleFormat('format', { title: generalNodeDictionary('publicKeyShort') })}
</RowLabel>
<PubKey pubKey={pubKey} alwaysShowCopyButton />
</span>
</NodeCardText>
<CollapsableContent className="inline-flex flex-wrap peer-checked:max-h-12 sm:gap-1 sm:peer-checked:max-h-5">
<RowLabel>
{titleFormat('format', { title: generalNodeDictionary('operatorAddress') })}
</RowLabel>
<PubKey pubKey={operatorAddress} expandOnHoverDesktopOnly />
</CollapsableContent>
<CollapsableContent>
<RowLabel>
{titleFormat('format', { title: stakingNodeDictionary('stakedBalance') })}
</RowLabel>
{formattedStakeBalance}
</CollapsableContent>
{!isSoloNode ? (
<CollapsableContent>
<RowLabel>
{titleFormat('format', { title: generalNodeDictionary('operatorFee') })}
</RowLabel>
{operatorFee !== null ? formatPercentage(operatorFee) : notFoundString}
</CollapsableContent>
) : null}
{showRawNodeData ? (
<>
<CollapsableContent className="hidden peer-checked:block">
<RowLabel>
{titleFormat('format', { title: generalNodeDictionary('rawData') })}
</RowLabel>
</CollapsableContent>
<CollapsableContent className="hidden peer-checked:block peer-checked:h-2" size="xs">
<ActionModuleDivider className="h-0.5" />
</CollapsableContent>
{Object.entries(node).map(([key, value]) => {
const valueToDisplay = JSON.stringify(value);
return (
<CollapsableContent
size="xs"
key={key}
className={cn(
'hidden peer-checked:block',
valueToDisplay.length > 100 ? 'peer-checked:max-h-8' : ''
)}
>
<RowLabel>{`${key}: `}</RowLabel>
<span>{valueToDisplay}</span>
</CollapsableContent>
);
})}
</>
) : null}
{!hideButton ? (
isReadyToExit(node, blockHeight) ? (
<NodeExitButtonDialog node={node} />
) : (isRequestingToExit(node, blockHeight) && !isBeingDeregistered(node)) ||
hasDeregistrationUnlockHeight(node) ? (
<Tooltip
tooltipContent={dictionary('exit.disabledButtonTooltipContent', {
relativeTime: deregistrationUnlockTime ?? requestedUnlockTime ?? notFoundString,
date: deregistrationUnlockDate
? formatDate(deregistrationUnlockDate, { dateStyle: 'full', timeStyle: 'short' })
: requestedUnlockDate
? formatDate(requestedUnlockDate, { dateStyle: 'full', timeStyle: 'short' })
: notFoundString,
})}
>
<NodeExitButton disabled />
</Tooltip>
) : !hasExited(node) ? (
<NodeRequestExitButton node={node} />
) : null
) : null}
</NodeCard>
);
}
);
StakedNodeCard.displayName = 'StakedNodeCard';
export { StakedNodeCard };