Skip to content

Commit

Permalink
Set up an action sheet for the deposit flow from the homepage; set up…
Browse files Browse the repository at this point in the history
… Redux for managing its state (#11)

* Set up Redux

* Add ListItem/ConditionalWrap components

* Create stub AssetIcon component

* Fix radii

* Create ListItems component

* Set up action sheet lib

* Modify store to be used for deposit flow

* Install expo-clipboard

* Allow buttons to be disabled

* Create ActionSheet component

* Add step to deposit flow state

* Create deposit flow

* Add help step

* Add docblock

* Remove unnecessary comments

* Tweak comment

* Set up i18n using Lingui (#12)

* Set up i18n using Lingui

* Require compilation

* Add more docs to ActionSheet

* Add property docs

* Add docs re: typed hooks

* Switch to enum; fix import
  • Loading branch information
jessepinho authored Dec 31, 2024
1 parent 3164cdd commit cedf501
Show file tree
Hide file tree
Showing 29 changed files with 1,544 additions and 108 deletions.
37 changes: 37 additions & 0 deletions react-native-expo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,40 @@ We use [Storybook](https://storybook.js.org/) to develop and view UI components
To make this possible, we use [`react-native-web`](https://necolas.github.io/react-native-web/) (as well as some other utility libraries) to convert React Native components into their HTML equivalents. See `.storybook/main.ts` for more.

Storybook stories should be placed next to the component file they represent, suffixed with `.stories.ts` or `.stories.tsx`. For example, the Storybook stories for a `Button` component would live under `Button/index.stories.ts`.

## Internationalization (i18n)

We use [Lingui](https://lingui.dev/) to manage translations in Prax Mobile.

When rendering literal text in the app, make sure the text is wrapped in either a `<Trans />` component or a `` t`...` `` call. (Get `t` from`useLingui()`: `const { t } = useLingui()`.)

When wrapping translatable text in `<Trans />`, make sure to put the `<Trans />` component as close to the translatable text as possible:

```tsx
<Text><Trans>Correct!</Trans></Text>
<Trans><Text>Incorrect!</Text></Trans>
```

This is because, when strings are extracted from your code (see below), any components between `<Trans />` and the translatable text will show up as `<0>`, `<1>`, etc. wrappers in the translation files.

For example, `<Trans><Text>Incorrect!</Text></Trans>` will be extracted as `<0>Incorrect!</0>` in the translation files. But `<Text><Trans>Correct!</Trans></Text>` will be extracted as just `Correct!` in the translation files, which is easier for translators to work with.

The only time you _should_ keep translatable text wrapped in a surrounding component _inside_ `<Trans />` is when multiple text components form a single unit, e.g., `<Trans><Text>This is a single sentence with a word in <Text style={{ fontWeight: 'bold' }}>bold</Text>.</Text></Trans>`.

(Note: Lingui does allow a [`defaultComponent` prop](https://lingui.dev/ref/react#i18nprovider) to wrap all translatable strings rendered in a `<Trans />` with a `<Text />` component. However, for the purpose of being explicit about what is being rendered, we just use Lingui _without_ the default component.)

### Extraction

Once you've added new translatable messages to the app via either `<Trans />` or `` t`...` ``, run `yarn extract` to extract the new strings to `locales/{locale}/messages.po`.

### Compilation

After messages have been extracted via `yarn extract`, run `yarn compile` to compile them to TypeScript files that can be read by the codebase.

## Redux

We use [Redux](https://redux.js.org/) for state management in Prax Mobile, along with [Redux Toolkit](https://redux-toolkit.js.org/).

### Typed hooks

To use `redux-react`'s `useSelector` and `useDispatch` hooks, do not import them directly. Instead, import the typed versions from `@/store/hooks`, which are bound to our store's TypeScript types.
7 changes: 7 additions & 0 deletions react-native-expo/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['@lingui/babel-plugin-lingui-macro'],
};
};
54 changes: 54 additions & 0 deletions react-native-expo/components/ActionSheet/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Sx, View } from 'dripsy';
import { ReactNode, useEffect, useRef } from 'react';
import RNASActionSheet, { ActionSheetRef } from 'react-native-actions-sheet';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

export interface ActionSheetProps {
children: ReactNode;
isOpen?: boolean;
onClose?: () => void;
}

/**
* Renders an action sheet that slides up from the bottom of the screen for UI
* flows that don't require an entire route/screen.
*
* Pass it `isOpen` and `onClose` to control its state with, e.g., Redux.
*
* ```tsx
* <ActionSheet isOpen={isOpen} onClose={onClose}>
* <View>
* <Text>ActionSheet content here.</Text>
* </View>
* </ActionSheet>
* ```
*
* For now, `<ActionSheet />` is implemented as a simple wrapper around
* `react-native-actions-sheet` that allows declarative control of the action
* sheet via `isOpen` and `onClose` props, rather than needing a `ref` to
* control the action sheet imperatively.
*
* Eventually, this will also apply Prax Wallet's proper styling to the action
* sheet, at which point this comment should be deleted/updated.
*/
export default function ActionSheet({ children, isOpen, onClose }: ActionSheetProps) {
const ref = useRef<ActionSheetRef>(null);
const insets = useSafeAreaInsets();

useEffect(() => {
if (isOpen) ref.current?.show();
else ref.current?.hide();
}, [isOpen]);

return (
<RNASActionSheet ref={ref} onClose={onClose} gestureEnabled safeAreaInsets={insets}>
<View sx={sx.root}>{children}</View>
</RNASActionSheet>
);
}

const sx = {
root: {
px: '$4',
},
} satisfies Record<string, Sx>;
12 changes: 12 additions & 0 deletions react-native-expo/components/AssetIcon/index.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Meta, StoryObj } from '@storybook/react';

import AssetIcon from '.';

const meta: Meta<typeof AssetIcon> = {
component: AssetIcon,
tags: ['autodocs'],
};

export default meta;

export const Basic: StoryObj<typeof AssetIcon> = {};
17 changes: 17 additions & 0 deletions react-native-expo/components/AssetIcon/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Sx, View } from 'dripsy';

/**
* A dummy component that, once populated with data, will render an icon for a
* specific asset.
*/
export default function AssetIcon() {
return <View sx={sx.root} />;
}

const sx = {
root: {
backgroundColor: 'neutralLight',
size: 40,
borderRadius: 20,
},
} satisfies Record<string, Sx>;
9 changes: 8 additions & 1 deletion react-native-expo/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ export interface ButtonProps {
*/
actionType?: ActionType;
onPress?: ((event: GestureResponderEvent) => void) | null;
disabled?: boolean;
}

export default function Button({ children, onPress, actionType = 'default' }: ButtonProps) {
export default function Button({
children,
onPress,
actionType = 'default',
disabled,
}: ButtonProps) {
return (
<Pressable
sx={{ ...sx.root, ...(actionType === 'accent' ? sx.accent : sx.default) }}
onPress={onPress}
disabled={disabled}
>
<Text variant='button' sx={actionType === 'accent' ? sx.textAccent : sx.textDefault}>
{children}
Expand Down
58 changes: 58 additions & 0 deletions react-native-expo/components/ConditionalWrap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ReactNode } from 'react';

export interface ConditionalWrapProps {
if: boolean;
then: (children: ReactNode) => ReactNode;
else?: (children: ReactNode) => ReactNode;
children: ReactNode;
}

/**
* Utility component to optionally wrap a React component with another React
* component, depending on a condition.
*
* @example
* ```tsx
* <ConditionalWrap
* if={shouldUseTooltip}
* then={(children) => (
* <Tooltip>
* <TooltipTrigger>{children}</TooltipTrigger>
* <TooltipContent>Here is the tooltip text.</TooltipContent>
* </Tooltip>
* )}
* >
* Here is the content that may or may not need a tooltip.
* </ConditionalWrap>
* ```
*
* You can also pass an `else` prop to wrap the `children` if the condition is
* _not_ met.
*
* @example
* ```tsx
* <ConditionalWrap
* if={shouldUseTooltip}
* then={(children) => (
* <Tooltip>
* <TooltipTrigger>{children}</TooltipTrigger>
* <TooltipContent>Here is the tooltip text.</TooltipContent>
* </Tooltip>
* )}
* else={(children) => (
* <NonTooltipWrapper>{children}</NonTooltipWrapper>
* )}
* >
* Here is the content that may or may not need a tooltip.
* </ConditionalWrap>
* ```
*
* @see https://stackoverflow.com/a/56870316/974981
*/
export const ConditionalWrap = ({
children,
if: condition,
then: thenWrapper,
else: elseWrapper,
}: ConditionalWrapProps) =>
condition ? thenWrapper(children) : elseWrapper ? elseWrapper(children) : children;
87 changes: 87 additions & 0 deletions react-native-expo/components/DepositFlow/Address.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Button from '../Button';
import * as Clipboard from 'expo-clipboard';
import Icon from '../Icon';
import { Info } from 'lucide-react-native';
import { Pressable, Sx, Text, View } from 'dripsy';
import { setStep } from '@/store/depositFlow';
import { useAppDispatch } from '@/store/hooks';
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';

const MOCK_PENUMBRA_ADDRESS =
'penumbra147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahh09cxmz';

export default function Address() {
const { t } = useLingui();

const BUTTON_PROPS_DEFAULT = {
children: t`Copy IBC address`,
disabled: false,
};

const BUTTON_PROPS_COPIED = {
children: t`Copied!`,
disabled: true,
};

const [buttonProps, setButtonProps] = useState(BUTTON_PROPS_DEFAULT);
const dispatch = useAppDispatch();

const copyToClipboard = async () => {
await Clipboard.setStringAsync(MOCK_PENUMBRA_ADDRESS);

setButtonProps(BUTTON_PROPS_COPIED);
setTimeout(() => setButtonProps(BUTTON_PROPS_DEFAULT), 2_000);
};

return (
<View sx={sx.root}>
<Text variant='large'>
<Trans>Shielded IBC deposit</Trans>
</Text>

<Text variant='small'>
<Trans>
This address rotates for each deposit to ensure privacy in Penumbra's shielded pool.
</Trans>
</Text>

<View sx={sx.addressWrapper}>
<Text variant='small'>{MOCK_PENUMBRA_ADDRESS}</Text>
</View>

<Pressable sx={sx.helpButton} onPress={() => dispatch(setStep('help'))}>
<Text variant='small'>
<Trans>What is a shielded IBC deposit?</Trans>
</Text>

<Icon IconComponent={Info} size='sm' />
</Pressable>

<Button actionType='accent' onPress={copyToClipboard} {...buttonProps} />
</View>
);
}

const sx = {
addressWrapper: {
borderRadius: 'lg',
borderColor: 'actionNeutralFocusOutline',
borderWidth: 1,

px: '$4',
py: '$2',
},

helpButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: '$2',
},

root: {
flexDirection: 'column',
gap: '$4',
},
} satisfies Record<string, Sx>;
37 changes: 37 additions & 0 deletions react-native-expo/components/DepositFlow/DepositMethod.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Text } from 'dripsy';
import ListItems from '../ListItems';
import ListItem from '../ListItem';
import AssetIcon from '../AssetIcon';
import Icon from '../Icon';
import { ChevronRight } from 'lucide-react-native';
import { setStep } from '@/store/depositFlow';
import { useAppDispatch } from '@/store/hooks';
import { Trans, useLingui } from '@lingui/react/macro';

export default function DepositMethod() {
const dispatch = useAppDispatch();
const { t } = useLingui();

return (
<>
<Text variant='large'>
<Trans>Select deposit method</Trans>
</Text>

<ListItems>
<ListItem
avatar={<AssetIcon />}
primaryText={t`Shielded IBC deposit`}
suffix={<Icon IconComponent={ChevronRight} size='md' color='neutralLight' />}
onPress={() => dispatch(setStep('address'))}
/>

<ListItem
avatar={<AssetIcon />}
primaryText={t`Noble USDC deposit`}
secondaryText={t`Coming soon`}
/>
</ListItems>
</>
);
}
36 changes: 36 additions & 0 deletions react-native-expo/components/DepositFlow/Help.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Button from '../Button';
import { setStep } from '@/store/depositFlow';
import { Sx, Text, View } from 'dripsy';
import { useAppDispatch } from '@/store/hooks';
import { Trans } from '@lingui/react/macro';

export default function Help() {
const dispatch = useAppDispatch();

return (
<View sx={sx.root}>
<Text variant='large'>
<Trans>Shielded IBC deposit</Trans>
</Text>

<Text>
<Trans>
A Shielded IBC Deposit allows you to transfer assets (e.g., ATOM, OSMO) from Cosmos-based
networks into Penumbra's shielded pool. Once deposited, your assets become private and
anonymous, ensuring maximum confidentiality when used within Penumbra.
</Trans>
</Text>

<Button actionType='accent' onPress={() => dispatch(setStep('address'))}>
<Trans>OK</Trans>
</Button>
</View>
);
}

const sx = {
root: {
flexDirection: 'column',
gap: '$4',
},
} satisfies Record<string, Sx>;
Loading

0 comments on commit cedf501

Please sign in to comment.