(null);
+
+ const activeStickyIndexRef = useRef(0);
+
+ const stickyIndexes = useMemo(
+ () => items.map((item, idx) => (item.isSticky ? idx : undefined)).filter(isPresent),
+ [items]
+ );
+
+ const isSticky = (index: number) => stickyIndexes.includes(index);
+
+ const isActiveSticky = (index: number) => activeStickyIndexRef.current === index;
+
+ const rowVirtualizer = useVirtualizer({
+ count: items.length,
+ estimateSize: (index: number) => getItemHeight(items[index]!),
+ getScrollElement: () => parentRef.current,
+ rangeExtractor: useCallback(
+ (range: Range) => {
+ activeStickyIndexRef.current =
+ [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0;
+
+ const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]);
+
+ return [...next].sort((a, b) => a - b);
+ },
+ [stickyIndexes]
+ ),
+ });
+
+ if (!hasMarketIds) {
+ return ;
+ }
+
+ const sortTypeLabel = sortItems.find((item) => item.value === sortType)?.label ?? '';
+
+ return (
+
+
+
+
+
+
+ {sortTypeLabel}
+ Sort by}
+ >
+
+
+
+
+
{
+ const { labelIconName, labelStringKey, isNew } = MARKET_FILTER_OPTIONS[value];
+ return {
+ label: labelIconName ? (
+
+ ) : (
+ stringGetter({
+ key: labelStringKey,
+ fallback: value,
+ })
+ ),
+ slotAfter: isNew && {stringGetter({ key: STRING_KEYS.NEW })},
+ value,
+ };
+ }) satisfies MenuItem[]
+ }
+ css={{
+ '--button-toggle-on-border': 'none',
+ '--button-toggle-off-border': 'solid var(--default-border-width) var(--color-border)',
+ '--button-toggle-off-backgroundColor': 'transparent',
+ '--button-toggle-on-backgroundColor': 'var(--color-layer-4)',
+ }}
+ value={filter}
+ onValueChange={setFilter}
+ overflow="scroll"
+ />
+
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+const ItemRenderer = ({ listItem }: { listItem: ListItem }) => {
+ const height = getItemHeight(listItem);
+
+ if (listItem.itemType === 'market') {
+ return ;
+ }
+
+ if (listItem.itemType === 'custom') {
+ return {listItem.item}
;
+ }
+
+ return (
+
+ {listItem.item}
+ {(listItem as any)?.slotRight}
+
+ );
+};
diff --git a/src/views/dialogs/MobileUserMenuDialog.tsx b/src/views/dialogs/MobileUserMenuDialog.tsx
new file mode 100644
index 0000000000..c245d7144a
--- /dev/null
+++ b/src/views/dialogs/MobileUserMenuDialog.tsx
@@ -0,0 +1,343 @@
+import { useState } from 'react';
+
+import { BonsaiCore } from '@/bonsai/ontology';
+import { useNavigate } from 'react-router-dom';
+import styled from 'styled-components';
+
+import { OnboardingState } from '@/constants/account';
+import { ButtonAction, ButtonShape, ButtonSize, ButtonStyle } from '@/constants/buttons';
+import { ComplianceStates } from '@/constants/compliance';
+import { MODERATE_DEBOUNCE_MS } from '@/constants/debounce';
+import { DialogTypes } from '@/constants/dialogs';
+import { STRING_KEYS } from '@/constants/localization';
+import { AppRoute } from '@/constants/routes';
+
+import { useAccounts } from '@/hooks/useAccounts';
+import { useComplianceState } from '@/hooks/useComplianceState';
+import { useStringGetter } from '@/hooks/useStringGetter';
+
+import { Button } from '@/components/Button';
+import { Dialog, DialogPlacement } from '@/components/Dialog';
+import { Icon, IconName } from '@/components/Icon';
+import { IconButton } from '@/components/IconButton';
+import { MixedColorFiatOutput } from '@/components/MixedColorFiatOutput';
+import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton';
+
+import { calculateCanAccountTrade } from '@/state/accountCalculators';
+import { getOnboardingState } from '@/state/accountSelectors';
+import { useAppDispatch, useAppSelector } from '@/state/appTypes';
+import { openDialog, setIsUserMenuOpen } from '@/state/dialogs';
+import { getIsUserMenuOpen } from '@/state/dialogsSelectors';
+import { selectIsTurnkeyConnected } from '@/state/walletSelectors';
+
+import { isTruthy } from '@/lib/isTruthy';
+import { orEmptyObj } from '@/lib/typeUtils';
+import { truncateAddress } from '@/lib/wallet';
+
+const UserMenuContent = () => {
+ const stringGetter = useStringGetter();
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const onboardingState = useAppSelector(getOnboardingState);
+ const { complianceState } = useComplianceState();
+ const canAccountTrade = useAppSelector(calculateCanAccountTrade);
+ const isTurnkeyConnected = useAppSelector(selectIsTurnkeyConnected);
+ const { equity, freeCollateral } = orEmptyObj(
+ useAppSelector(BonsaiCore.account.parentSubaccountSummary.data)
+ );
+
+ const isLoading =
+ useAppSelector(BonsaiCore.account.parentSubaccountSummary.loading) === 'pending';
+
+ const {
+ sourceAccount: { address },
+ dydxAddress,
+ } = useAccounts();
+
+ const [walletDisplay, setWalletDisplay] = useState<'chain' | 'source'>('chain');
+
+ const [copied, setCopied] = useState(false);
+
+ const onCopy = () => {
+ if (walletDisplay === 'chain') {
+ if (!dydxAddress) return;
+ setCopied(true);
+ navigator.clipboard.writeText(dydxAddress);
+ setTimeout(() => setCopied(false), MODERATE_DEBOUNCE_MS);
+ } else {
+ if (!address) return;
+ setCopied(true);
+ navigator.clipboard.writeText(address);
+ setTimeout(() => setCopied(false), MODERATE_DEBOUNCE_MS);
+ }
+ };
+
+ const menuItems = [
+ onboardingState === OnboardingState.AccountConnected && {
+ key: 'history',
+ label: stringGetter({ key: STRING_KEYS.HISTORY }),
+ icon: ,
+ onClick: () => {
+ navigate(AppRoute.Portfolio);
+ },
+ },
+ onboardingState === OnboardingState.AccountConnected && {
+ key: 'settings',
+ label: stringGetter({ key: STRING_KEYS.SETTINGS }),
+ icon: ,
+ onClick: () => {
+ navigate(AppRoute.Settings);
+ },
+ },
+ {
+ key: 'help',
+ label: stringGetter({ key: STRING_KEYS.HELP }),
+ icon: ,
+ onClick: () => {
+ dispatch(openDialog(DialogTypes.Help()));
+ },
+ },
+ onboardingState === OnboardingState.AccountConnected &&
+ (isTurnkeyConnected
+ ? {
+ key: 'export-turnkey',
+ label: stringGetter({ key: STRING_KEYS.ACCOUNT_MANAGEMENT }),
+ icon: ,
+ onClick: () => {
+ dispatch(openDialog(DialogTypes.ManageAccount()));
+ },
+ }
+ : {
+ key: 'export-keys',
+ label: stringGetter({ key: STRING_KEYS.EXPORT_DYDX_WALLET }),
+ icon: ,
+ onClick: () => {
+ dispatch(openDialog(DialogTypes.MnemonicExport()));
+ },
+ }),
+ onboardingState !== OnboardingState.Disconnected && {
+ key: 'disconnect-wallet',
+ label: stringGetter({ key: STRING_KEYS.SIGN_OUT }),
+ highlightColor: 'var(--color-red)',
+ icon: ,
+ withoutCaret: true,
+ onClick: () => {
+ dispatch(openDialog(DialogTypes.DisconnectWallet()));
+ },
+ },
+ ].filter(isTruthy);
+
+ const walletDisplayRow = canAccountTrade ? (
+
+
+
+
+ ) : (
+
+ );
+
+ const userContent = (
+ <$UserContent>
+
+
+
+

+
+
+
+
+
+
+
+
+ {stringGetter({ key: STRING_KEYS.BALANCE })}
+
+
+
+
+ {walletDisplayRow}
+ $UserContent>
+ );
+
+ const isTransferDisabled = !canAccountTrade || isLoading;
+ const hasBalance = freeCollateral?.gt(0);
+ const showWithdrawOnly = canAccountTrade && hasBalance;
+ const showAllTransferOptions =
+ canAccountTrade && complianceState === ComplianceStates.FULL_ACCESS;
+
+ const transferContent = showAllTransferOptions ? (
+
+
+ {
+ dispatch(openDialog(DialogTypes.Withdraw2()));
+ }}
+ />
+ {
+ dispatch(openDialog(DialogTypes.Transfer({})));
+ }}
+ />
+
+ ) : showWithdrawOnly ? (
+
+
+
+ ) : null;
+
+ const menuContent = (
+ <$MenuContent>
+ {menuItems.map((item) => (
+
+ ))}
+ $MenuContent>
+ );
+
+ return (
+
+ {userContent}
+ {transferContent}
+ {menuContent}
+
+ );
+};
+
+export const UserMenuDialog = () => {
+ const stringGetter = useStringGetter();
+ const dispatch = useAppDispatch();
+ const isUserMenuOpen = useAppSelector(getIsUserMenuOpen);
+
+ const toggleUserMenu = (isOpen: boolean) => {
+ dispatch(setIsUserMenuOpen(isOpen));
+ };
+
+ return (
+ <$Dialog
+ isOpen={isUserMenuOpen}
+ title={stringGetter({ key: STRING_KEYS.MENU })}
+ setIsOpen={toggleUserMenu}
+ placement={DialogPlacement.Inline}
+ >
+
+ $Dialog>
+ );
+};
+
+const $UserContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ border-radius: 1rem;
+ border: solid 1.6px var(--color-layer-2);
+ padding: 1rem;
+`;
+
+const $MenuContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ border-radius: 1rem;
+ overflow: hidden;
+ gap: 1px;
+`;
+
+const $Caret = styled(Icon)`
+ --icon-size: 0.5rem;
+ transform: rotate(0.75turn);
+`;
+
+const $Dialog = styled(Dialog)`
+ --dialog-backgroundColor: var(--color-layer-1);
+ --dialog-header-backgroundColor: var(--color-layer-1);
+ box-shadow: none;
+ z-index: 3;
+`;
diff --git a/src/views/dialogs/SetMarketLeverageDialog.tsx b/src/views/dialogs/SetMarketLeverageDialog.tsx
index b775a33b4b..661b6abcde 100644
--- a/src/views/dialogs/SetMarketLeverageDialog.tsx
+++ b/src/views/dialogs/SetMarketLeverageDialog.tsx
@@ -214,7 +214,7 @@ export const SetMarketLeverageDialog = ({
isOpen
setIsOpen={setIsOpen}
title={stringGetter({ key: STRING_KEYS.SET_MARKET_LEVERAGE })}
- tw="[--dialog-header-paddingBottom:1.5rem] [--dialog-width:25rem]"
+ tw="[--dialog-header-paddingBottom:1.5rem]"
>