From 3f4c40c431ea83553d7d44e59463e45636c68c93 Mon Sep 17 00:00:00 2001 From: HeesungB Date: Mon, 27 May 2024 22:31:29 +0900 Subject: [PATCH] Add `Activities` Tab --- .../src/components/icon/document-fill.tsx | 19 + .../src/components/icon/document-outliend.tsx | 17 + apps/mobile/src/hooks/index.ts | 1 + .../src/hooks/use-pagination-cursor-query.ts | 194 ++++++++++ apps/mobile/src/languages/en.json | 3 + apps/mobile/src/languages/ko.json | 3 + apps/mobile/src/navigation.tsx | 26 ++ .../mobile/src/screen/activities/constants.ts | 18 + apps/mobile/src/screen/activities/index.tsx | 332 ++++++++++++++++++ .../mobile/src/screen/activities/messages.tsx | 134 +++++++ .../src/screen/activities/msg-items/base.tsx | 225 ++++++++++++ .../msg-items/cancel-undelegate.tsx | 107 ++++++ .../screen/activities/msg-items/delegate.tsx | 97 +++++ .../activities/msg-items/ibc-receive.tsx | 80 +++++ .../msg-items/ibc-send-refunded.tsx | 60 ++++ .../screen/activities/msg-items/ibc-send.tsx | 91 +++++ .../activities/msg-items/ibc-swap-receive.tsx | 239 +++++++++++++ .../msg-items/ibc-swap-refunded.tsx | 60 ++++ .../screen/activities/msg-items/ibc-swap.tsx | 211 +++++++++++ .../src/screen/activities/msg-items/index.tsx | 258 ++++++++++++++ .../src/screen/activities/msg-items/logo.tsx | 22 ++ .../msg-items/merged-claim-rewards.tsx | 253 +++++++++++++ .../screen/activities/msg-items/receive.tsx | 87 +++++ .../activities/msg-items/redelegate.tsx | 129 +++++++ .../src/screen/activities/msg-items/send.tsx | 84 +++++ .../screen/activities/msg-items/skeleton.tsx | 61 ++++ .../activities/msg-items/undelegate.tsx | 98 ++++++ .../src/screen/activities/msg-items/vote.tsx | 115 ++++++ apps/mobile/src/screen/activities/types.ts | 57 +++ 29 files changed, 3081 insertions(+) create mode 100644 apps/mobile/src/components/icon/document-fill.tsx create mode 100644 apps/mobile/src/components/icon/document-outliend.tsx create mode 100644 apps/mobile/src/hooks/use-pagination-cursor-query.ts create mode 100644 apps/mobile/src/screen/activities/constants.ts create mode 100644 apps/mobile/src/screen/activities/index.tsx create mode 100644 apps/mobile/src/screen/activities/messages.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/base.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/cancel-undelegate.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/delegate.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-receive.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-send-refunded.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-send.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-swap-receive.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-swap-refunded.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/ibc-swap.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/index.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/logo.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/merged-claim-rewards.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/receive.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/redelegate.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/send.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/skeleton.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/undelegate.tsx create mode 100644 apps/mobile/src/screen/activities/msg-items/vote.tsx create mode 100644 apps/mobile/src/screen/activities/types.ts diff --git a/apps/mobile/src/components/icon/document-fill.tsx b/apps/mobile/src/components/icon/document-fill.tsx new file mode 100644 index 0000000000..8bfddd73fb --- /dev/null +++ b/apps/mobile/src/components/icon/document-fill.tsx @@ -0,0 +1,19 @@ +import React, {FunctionComponent} from 'react'; +import {IconProps} from './types'; +import {Path, Svg} from 'react-native-svg'; + +export const DocumentFillIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/components/icon/document-outliend.tsx b/apps/mobile/src/components/icon/document-outliend.tsx new file mode 100644 index 0000000000..95c225aaf1 --- /dev/null +++ b/apps/mobile/src/components/icon/document-outliend.tsx @@ -0,0 +1,17 @@ +import React, {FunctionComponent} from 'react'; +import {IconProps} from './types'; +import {Path, Svg} from 'react-native-svg'; + +export const DocumentOutlinedIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/hooks/index.ts b/apps/mobile/src/hooks/index.ts index 6cacbc422b..35e91e0678 100644 --- a/apps/mobile/src/hooks/index.ts +++ b/apps/mobile/src/hooks/index.ts @@ -1 +1,2 @@ export * from './use-effect-once'; +export * from './use-pagination-cursor-query.ts'; diff --git a/apps/mobile/src/hooks/use-pagination-cursor-query.ts b/apps/mobile/src/hooks/use-pagination-cursor-query.ts new file mode 100644 index 0000000000..68b6f7acda --- /dev/null +++ b/apps/mobile/src/hooks/use-pagination-cursor-query.ts @@ -0,0 +1,194 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {simpleFetch} from '@keplr-wallet/simple-fetch'; + +export const usePaginatedCursorQuery = ( + baseURL: string, + initialUriFn: () => string, + nextCursorQueryString: (page: number, prev: R) => Record, + isEndedFn: (prev: R) => boolean, + refreshKey?: string, + isValidKey?: (key: string) => boolean, +): { + isFetching: boolean; + pages: { + response: R | undefined; + error?: Error; + }[]; + next: () => void; + refresh: () => void; +} => { + const [pages, setPages] = useState< + { + response: R | undefined; + error?: Error; + }[] + >([]); + + const baseURLRef = useRef(baseURL); + baseURLRef.current = baseURL; + const initialUriFnRef = useRef(initialUriFn); + initialUriFnRef.current = initialUriFn; + const nextCursorQueryStringRef = useRef(nextCursorQueryString); + nextCursorQueryStringRef.current = nextCursorQueryString; + const isEndedFnRef = useRef(isEndedFn); + isEndedFnRef.current = isEndedFn; + + const isValidKeyRef = useRef(isValidKey); + isValidKeyRef.current = isValidKey; + + // refresh를 할때 이전에 쿼리가 진행 중이면 + // 두 쿼리 중에 뭐가 먼저 끝나느냐에 따라서 결과가 달라진다... + // 쿼리는 cancel하는게 맞겠지만 + // 귀찮으니 그냥 seq를 이용해서 처리한다. + const currentQuerySeq = useRef(0); + useEffect(() => { + currentQuerySeq.current++; + }, [refreshKey]); + + // 어차피 바로 useEffect에 의해서 fetch되기 때문에 true로 시작... + const [isFetching, setIsFetching] = useState(true); + const _initialFetchIsDuringDeferred = useRef(false); + const _initialFetch = () => { + const fetch = () => { + if (_initialFetchIsDuringDeferred.current) { + return; + } + + const querySeq = currentQuerySeq.current; + simpleFetch(baseURLRef.current, initialUriFnRef.current()) + .then(r => { + if (querySeq === currentQuerySeq.current) { + setPages([ + { + response: r.data, + }, + ]); + } + }) + .catch(e => { + if (querySeq === currentQuerySeq.current) { + setPages([ + { + response: undefined, + error: e, + }, + ]); + } + }) + .finally(() => { + if (querySeq === currentQuerySeq.current) { + setIsFetching(false); + } + }); + }; + + fetch(); + }; + const initialFetchRef = useRef(_initialFetch); + initialFetchRef.current = _initialFetch; + useEffect(() => { + if ( + !isValidKeyRef.current || + !refreshKey || + isValidKeyRef.current(refreshKey) + ) { + initialFetchRef.current(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [prevRefreshKey, setPrevRefreshKey] = useState(refreshKey); + useEffect(() => { + // 위에서 빈 deps의 useEffect에 의해서 처음에 쿼리가 발생한다. + // prevRefreshKey 로직이 없으면 이 로직도 실행되면서 쿼리가 두번 발생한다. + // 이 문제를 해결하려고 prevRefreshKey를 사용한다. + if (prevRefreshKey !== refreshKey) { + if ( + !isValidKeyRef.current || + !refreshKey || + isValidKeyRef.current(refreshKey) + ) { + setPages([]); + setIsFetching(true); + initialFetchRef.current(); + } + + setPrevRefreshKey(refreshKey); + } + }, [prevRefreshKey, refreshKey]); + + const next = useCallback(() => { + if (isFetching || pages.length === 0) { + return; + } + + const res = pages[pages.length - 1].response; + if (!res) { + return; + } + if (isEndedFnRef.current(res)) { + return; + } + + const nextPage = pages.length + 1; + let uri = initialUriFnRef.current(); + const qs = nextCursorQueryStringRef.current(nextPage, res); + const params = new URLSearchParams(qs); + if (uri.length === 0 || uri === '/') { + uri = `?${params.toString()}`; + } else { + uri += `&${params.toString()}`; + } + + setIsFetching(true); + const querySeq = currentQuerySeq.current; + simpleFetch(baseURLRef.current, uri) + .then(r => { + if (querySeq === currentQuerySeq.current) { + setPages(prev => { + const newPages = prev.slice(); + newPages.push({ + response: r.data, + }); + return newPages; + }); + } + }) + .catch(e => { + if (querySeq === currentQuerySeq.current) { + setPages(prev => { + const newPages = prev.slice(); + newPages.push({ + response: undefined, + error: e, + }); + return newPages; + }); + } + }) + .finally(() => { + if (querySeq === currentQuerySeq.current) { + setIsFetching(false); + } + }); + }, [isFetching, pages]); + + const refresh = useCallback(() => { + if (isFetching) { + return; + } + + setPages([]); + setIsFetching(true); + + initialFetchRef.current(); + }, [isFetching]); + + return useMemo(() => { + return { + isFetching, + pages, + next, + refresh, + }; + }, [isFetching, next, pages, refresh]); +}; diff --git a/apps/mobile/src/languages/en.json b/apps/mobile/src/languages/en.json index 53d421a0b7..c49bbef1e5 100644 --- a/apps/mobile/src/languages/en.json +++ b/apps/mobile/src/languages/en.json @@ -380,6 +380,9 @@ "page.ibc-swap.components.slippage-modal.title": "Slippage Settings", "page.ibc-swap.components.slippage-modal.label.slippage-tolerance": "Slippage Tolerance", "page.ibc-swap.components.slippage-modal.label.slippage-custom": "Custom Slippage", + + "page.activity.title": "Activity", + "page.permission.unknown-permission": "Unknown permission", "page.permission.requesting-connection-title": "Requesting Connection", "page.permission.paragraph": "By approving this request, the website will:{br}• Get the list of all chain on your Keplr Wallet", diff --git a/apps/mobile/src/languages/ko.json b/apps/mobile/src/languages/ko.json index 8e44c10c65..f88135d902 100644 --- a/apps/mobile/src/languages/ko.json +++ b/apps/mobile/src/languages/ko.json @@ -377,6 +377,9 @@ "page.ibc-swap.components.slippage-modal.title": "슬리피지 설정", "page.ibc-swap.components.slippage-modal.label.slippage-tolerance": "슬리피지 허용치", "page.ibc-swap.components.slippage-modal.label.slippage-custom": "커스텀 설정", + + "page.activity.title": "활동내역", + "page.permission.unknown-permission": "알수없는 권한", "page.permission.requesting-connection-title": "연결 요청", "page.permission.paragraph": "승인시 해당 웹사이트에서 아래와 같은 권한을 받습니다 :{br}• 유저의 케플러 지갑에 있는 모든 체인 리스트 조회, 사용", diff --git a/apps/mobile/src/navigation.tsx b/apps/mobile/src/navigation.tsx index b8f430064e..4925535f28 100644 --- a/apps/mobile/src/navigation.tsx +++ b/apps/mobile/src/navigation.tsx @@ -121,6 +121,9 @@ import {EditFavoriteUrlScreen} from './screen/web/edit-favorite'; import {SearchUrlScreen} from './screen/web/search'; import {FavoriteUrl} from './stores/webpage/types.ts'; import {Text} from 'react-native'; +import {ActivitiesScreen} from './screen/activities'; +import {DocumentFillIcon} from './components/icon/document-fill.tsx'; +import {DocumentOutlinedIcon} from './components/icon/document-outliend.tsx'; type DefaultRegisterParams = { hideBackButton?: boolean; @@ -340,6 +343,8 @@ export type RootStackParamList = { initialGasAdjustment?: string; tempSwitchAmount?: string; }; + + Activities: undefined; }; export type StakeNavigation = { @@ -584,6 +589,12 @@ const DrawerBottomTabLabel: FunctionComponent<{ {intl.formatMessage({id: 'bottom-tabs.settings'})} ); + case 'Activities': + return ( + + {intl.formatMessage({id: 'bottom-tabs.activity'})} + + ); } return <>; @@ -604,6 +615,7 @@ export const MainTabNavigation: FunctionComponent = () => { if ( focusedScreen.name !== 'Home' && focusedScreen.name !== 'Swap' && + focusedScreen.name !== 'Activities' && isDrawerOpen ) { navigation.dispatch(DrawerActions.toggleDrawer()); @@ -633,6 +645,12 @@ export const MainTabNavigation: FunctionComponent = () => { ) : ( ); + case 'Activities': + return focused ? ( + + ) : ( + + ); } }, tabBarLabel: ({color}) => @@ -669,6 +687,14 @@ export const MainTabNavigation: FunctionComponent = () => { options={{headerShown: false}} component={WebNavigation} /> + { + return chainInfo.chainId !== this.baseChainId; + }) + .filter(chainInfo => { + const baseAccount = this.accountStore.getAccount(this.baseChainId); + const account = this.accountStore.getAccount(chainInfo.chainId); + if (!account.bech32Address) { + return false; + } + return ( + Buffer.from( + Bech32Address.fromBech32(account.bech32Address).address, + ).toString('hex') !== + Buffer.from( + Bech32Address.fromBech32(baseAccount.bech32Address).address, + ).toString('hex') + ); + }) + .map(chainInfo => { + const account = this.accountStore.getAccount(chainInfo.chainId); + return { + chainIdentifier: chainInfo.chainIdentifier, + bech32Address: account.bech32Address, + }; + }); + } + + return []; + } +} + +export const ActivitiesScreen: FunctionComponent = observer(() => { + const {chainStore, accountStore, queriesStore, priceStore} = useStore(); + + const [otherBech32Addresses] = useState( + () => new OtherBech32Addresses(chainStore, accountStore, 'cosmoshub'), + ); + const account = accountStore.getAccount('cosmoshub'); + const [selectedKey, setSelectedKey] = useState('__all__'); + + const querySupported = queriesStore.simpleQuery.queryGet( + process.env['KEPLR_EXT_CONFIG_SERVER'] || '', + '/tx-history/supports', + ); + + const supportedChainList = useMemo(() => { + const map = new Map(); + for (const chainIdentifier of querySupported.response?.data ?? []) { + map.set(chainIdentifier, true); + } + + return chainStore.chainInfosInListUI.filter(chainInfo => { + return map.get(chainInfo.chainIdentifier) ?? false; + }); + }, [chainStore.chainInfosInListUI, querySupported.response?.data]); + + otherBech32Addresses.setSupportedChainList(supportedChainList); + + const msgHistory = usePaginatedCursorQuery( + process.env['KEPLR_EXT_TX_HISTORY_BASE_URL'] || '', + () => { + return `/history/msgs/keplr-multi-chain?baseBech32Address=${ + account.bech32Address + }&chainIdentifiers=${(() => { + if (selectedKey === '__all__') { + return supportedChainList + .map(chainInfo => chainInfo.chainId) + .join(','); + } + return selectedKey; + })()}&relations=${Relations.join(',')}&vsCurrencies=${ + priceStore.defaultVsCurrency + }&limit=${PaginationLimit}${(() => { + if (otherBech32Addresses.otherBech32Addresses.length === 0) { + return ''; + } + return `&otherBech32Addresses=${otherBech32Addresses.otherBech32Addresses + .map(address => `${address.chainIdentifier}:${address.bech32Address}`) + .join(',')}`; + })()}`; + }, + (_, prev) => { + return { + cursor: prev.nextCursor, + }; + }, + res => { + if (!res.nextCursor) { + return true; + } + return false; + }, + `${selectedKey}/${supportedChainList + .map(chainInfo => chainInfo.chainId) + .join(',')}/${otherBech32Addresses.otherBech32Addresses + .map(address => `${address.chainIdentifier}:${address.bech32Address}`) + .join(',')}`, + (key: string) => { + // key가 아래와 같으면 querySupported나 account 중 하나도 load되지 않은 경우다. + // 이런 경우 query를 할 필요가 없다. + return key !== `${selectedKey}//`; + }, + ); + + const style = useStyle(); + + return ( + { + const bottomPadding = 30; + + if ( + nativeEvent.layoutMeasurement.height + nativeEvent.contentOffset.y >= + nativeEvent.contentSize.height - bottomPadding + ) { + msgHistory.next(); + } + }} + refreshControl={ + msgHistory.pages.length > 0 ? ( + msgHistory.refresh()} + tintColor={style.get('color-gray-200').color} + /> + ) : undefined + }> + + + + + + + + { + setSelectedKey(key); + }} + items={[ + { + key: '__all__', + label: 'All', + }, + ...supportedChainList.map(chainInfo => { + return { + key: chainInfo.chainId, + label: chainInfo.chainName, + }; + }), + ]} + /> + + + {(() => { + if (msgHistory.pages.length === 0) { + return ( + + + + + + + + + + + + + + + + + + + + ); + } + if (msgHistory.pages.find(page => page.error != null)) { + return ( + + + + + }> + + + Network error. + + + Please try again after a few minutes. + + + + + ); + } + + // 아무 history도 없는 경우 + if (msgHistory.pages[0].response?.msgs.length === 0) { + return ( + + + + + No recent transaction history + + + + + ); + } + + return ( + { + // "custom/merged-claim-rewards"는 예외임 + if (msg.relation === 'custom/merged-claim-rewards') { + if (!msg.denoms || msg.denoms.length === 0) { + throw new Error(`Invalid denoms: ${msg.denoms})`); + } + const chainInfo = chainStore.getChain(msg.chainId); + if (chainInfo.chainIdentifier === 'dydx-mainnet') { + // dydx는 USDC에 우선권을 줌 + if ( + msg.denoms.includes( + 'ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5', + ) + ) { + return 'ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5'; + } + } + if (chainInfo.stakeCurrency) { + if ( + msg.denoms.includes( + chainInfo.stakeCurrency.coinMinimalDenom, + ) + ) { + return chainInfo.stakeCurrency.coinMinimalDenom; + } + } + return msg.denoms[0]; + } + if (!msg.denoms || msg.denoms.length !== 1) { + // 백엔드에서 denoms는 무조건 한개 오도록 보장한다. + throw new Error(`Invalid denoms: ${msg.denoms})`); + } + + return msg.denoms[0]; + }} + isInAllActivitiesPage={true} + /> + ); + })()} + + ); +}); diff --git a/apps/mobile/src/screen/activities/messages.tsx b/apps/mobile/src/screen/activities/messages.tsx new file mode 100644 index 0000000000..683cbc2042 --- /dev/null +++ b/apps/mobile/src/screen/activities/messages.tsx @@ -0,0 +1,134 @@ +import React, {FunctionComponent} from 'react'; +import {observer} from 'mobx-react-lite'; +import {ResMsgsHistory} from './types.ts'; +import {usePaginatedCursorQuery} from '../../hooks'; +import {Box} from '../../components/box'; +import {FormattedDate} from 'react-intl'; +import {Text} from 'react-native'; +import {useStyle} from '../../styles'; +import {Stack} from '../../components/stack'; +import {useStore} from '../../stores'; +import {MsgItemRender} from './msg-items'; + +export const RenderMessages: FunctionComponent<{ + msgHistory: ReturnType>; + targetDenom: string | ((msg: ResMsgsHistory['msgs'][0]['msg']) => string); + isInAllActivitiesPage?: boolean; +}> = observer(({msgHistory, targetDenom, isInAllActivitiesPage}) => { + const style = useStyle(); + + const {chainStore} = useStore(); + + const msgsPerDaily: { + year: number; + month: number; + day: number; + msgs: ResMsgsHistory['msgs']; + }[] = (() => { + if (msgHistory.pages.length === 0) { + return []; + } + + const res: { + year: number; + month: number; + day: number; + msgs: ResMsgsHistory['msgs']; + }[] = []; + + // prop 자체로부터 이미 내림차순된 채로 온다고 가정하고 작성한다. + for (const page of msgHistory.pages) { + if (page.response) { + for (const msg of page.response.msgs) { + if (res.length === 0) { + const time = new Date(msg.msg.time); + res.push({ + year: time.getFullYear(), + month: time.getMonth(), + day: time.getDate(), + msgs: [msg], + }); + } else { + const last = res[res.length - 1]; + const time = new Date(msg.msg.time); + if ( + last.year === time.getFullYear() && + last.month === time.getMonth() && + last.day === time.getDate() + ) { + last.msgs.push(msg); + } else { + res.push({ + year: time.getFullYear(), + month: time.getMonth(), + day: time.getDate(), + msgs: [msg], + }); + } + } + } + } + } + + return res; + })(); + + return ( + + {msgsPerDaily.map((msgs, i) => { + return ( + + + + + + + + + {msgs.msgs.map(msg => { + const denom = (() => { + if (typeof targetDenom === 'string') { + return targetDenom; + } + return targetDenom(msg.msg); + })(); + + const currency = chainStore + .getChain(msg.msg.chainId) + .findCurrency(denom); + // 알려진 currency가 있는 경우에만 렌더링한다. + // 사실 토큰 디테일에서 렌더링 되는 경우에는 이 로직이 필요가 없지만 + // All activities 페이지에서는 백엔드에서 어떤 denom이 올지 확신할 수 없고 + // 알려진 currency의 경우만 보여줘야하기 때문에 이 로직이 중요하다. + if (!currency) { + return null; + } + if ( + currency.coinMinimalDenom.startsWith('ibc/') && + (!('originCurrency' in currency) || !currency.originCurrency) + ) { + return null; + } + + return ( + + ); + })} + + + ); + })} + + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/base.tsx b/apps/mobile/src/screen/activities/msg-items/base.tsx new file mode 100644 index 0000000000..10995ecbc3 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/base.tsx @@ -0,0 +1,225 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {CoinPretty, Dec, PricePretty} from '@keplr-wallet/unit'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {Box} from '../../../components/box'; +import {useStyle} from '../../../styles'; +import {XAxis, YAxis} from '../../../components/axis'; +import {Text, ViewStyle} from 'react-native'; +import {ItemLogo} from './logo.tsx'; +import {Gutter} from '../../../components/gutter'; +import * as ExpoImage from 'expo-image'; +import {RectButton} from '../../../components/rect-button'; +import {useNavigation} from '@react-navigation/native'; +import {StackNavProp} from '../../../navigation.tsx'; + +export const MsgItemBase: FunctionComponent<{ + logo: React.ReactElement; + chainId: string; + title: string; + paragraph?: string; + paragraphStyle?: ViewStyle; + bottom?: React.ReactElement; + amount: CoinPretty | string; + overrideAmountColor?: string; + prices: Record | undefined>; + msg: MsgHistory; + targetDenom: string; + amountDeco?: { + prefix: 'none' | 'plus' | 'minus'; + color: 'none' | 'green'; + }; + isInAllActivitiesPage: boolean | undefined; +}> = observer( + ({ + logo, + chainId, + title, + paragraph, + paragraphStyle, + bottom, + amount, + overrideAmountColor, + prices, + msg, + targetDenom, + amountDeco, + isInAllActivitiesPage, + }) => { + const {chainStore, priceStore, queriesStore} = useStore(); + + const style = useStyle(); + const navigation = useNavigation(); + + const chainInfo = chainStore.getChain(chainId); + + // mobx와 useMemo의 조합 문제로... 값 몇개를 밖으로 뺀다. + const foundCurrency = chainInfo.findCurrency(targetDenom); + const defaultVsCurrency = priceStore.defaultVsCurrency; + const sendAmountPricePretty = useMemo(() => { + if (typeof amount === 'string') { + return undefined; + } + + if (foundCurrency && foundCurrency.coinGeckoId) { + const price = prices[foundCurrency.coinGeckoId]; + if (price != null && price[defaultVsCurrency] != null) { + const dec = amount.toDec(); + const priceDec = new Dec(price[defaultVsCurrency]!.toString()); + const fiatCurrency = priceStore.getFiatCurrency(defaultVsCurrency); + if (fiatCurrency) { + return new PricePretty(fiatCurrency, dec.mul(priceDec)); + } + } + } + return; + }, [defaultVsCurrency, foundCurrency, priceStore, prices, amount]); + + const queryExplorer = queriesStore.simpleQuery.queryGet<{ + link: string; + }>( + process.env['KEPLR_EXT_CONFIG_SERVER'] || '', + `/tx-history/explorer/${chainInfo.chainIdentifier}`, + ); + + const explorerUrl = queryExplorer.response?.data.link || ''; + + const clickable = !!explorerUrl; + + return ( + + { + if (explorerUrl) { + navigation.navigate('Web', { + url: explorerUrl + .replace('{txHash}', msg.txHash.toUpperCase()) + .replace('{txHash:lowercase}', msg.txHash.toLowerCase()) + .replace('{txHash:uppercase}', msg.txHash.toUpperCase()), + isExternal: true, + }); + } + }} + style={style.flatten(['padding-x-16', 'padding-y-14'])}> + + + + {isInAllActivitiesPage ? ( + + ) : null} + + + + + + + + {title} + + + {paragraph ? ( + + + + + {paragraph} + + + ) : null} + + + + + + + {(() => { + if (msg.code !== 0) { + return ( + + Failed + + ); + } + + return ( + + + {(() => { + if (!amountDeco) { + return ''; + } + + if (amountDeco.prefix === 'plus') { + return '+'; + } + + if (amountDeco.prefix === 'minus') { + return '-'; + } + + return ''; + })()} + {typeof amount === 'string' + ? amount + : amount + .maxDecimals(2) + .shrink(true) + .hideIBCMetadata(true) + .inequalitySymbol(true) + .inequalitySymbolSeparator('') + .toString()} + + + {sendAmountPricePretty ? ( + + + + {sendAmountPricePretty.toString()} + + + ) : null} + + ); + })()} + + + + + {bottom} + + ); + }, +); diff --git a/apps/mobile/src/screen/activities/msg-items/cancel-undelegate.tsx b/apps/mobile/src/screen/activities/msg-items/cancel-undelegate.tsx new file mode 100644 index 0000000000..366e3bb5a2 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/cancel-undelegate.tsx @@ -0,0 +1,107 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Staking} from '@keplr-wallet/stores'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; +import {useStyle} from '../../../styles'; + +export const MsgRelationCancelUndelegate: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore, queriesStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const amountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amount = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }; + + if (amount.denom !== targetDenom) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amount.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const validatorAddress: string = useMemo(() => { + return (msg.msg as any)['validator_address']; + }, [msg.msg]); + + const queryBonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Bonded); + const queryUnbonding = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonding); + const queryUnbonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonded); + + const moniker: string = (() => { + if (!validatorAddress) { + return 'Unknown'; + } + const bonded = queryBonded.getValidator(validatorAddress); + if (bonded?.description.moniker) { + return bonded.description.moniker; + } + const unbonding = queryUnbonding.getValidator(validatorAddress); + if (unbonding?.description.moniker) { + return unbonding.description.moniker; + } + const unbonded = queryUnbonded.getValidator(validatorAddress); + if (unbonded?.description.moniker) { + return unbonded.description.moniker; + } + + return 'Unknown'; + })(); + + return ( + + } + chainId={msg.chainId} + title="Cancel Unstaking" + paragraph={`From ${moniker}`} + amount={amountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const CancelRedelegateIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/delegate.tsx b/apps/mobile/src/screen/activities/msg-items/delegate.tsx new file mode 100644 index 0000000000..dcfceca7cf --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/delegate.tsx @@ -0,0 +1,97 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Staking} from '@keplr-wallet/stores'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; + +export const MsgRelationDelegate: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore, queriesStore} = useStore(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const amountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amount = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }; + + if (amount.denom !== targetDenom) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amount.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const validatorAddress: string = useMemo(() => { + return (msg.msg as any)['validator_address']; + }, [msg.msg]); + + const queryBonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Bonded); + const queryUnbonding = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonding); + const queryUnbonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonded); + + const moniker: string = (() => { + if (!validatorAddress) { + return 'Unknown'; + } + const bonded = queryBonded.getValidator(validatorAddress); + if (bonded?.description.moniker) { + return bonded.description.moniker; + } + const unbonding = queryUnbonding.getValidator(validatorAddress); + if (unbonding?.description.moniker) { + return unbonding.description.moniker; + } + const unbonded = queryUnbonded.getValidator(validatorAddress); + if (unbonded?.description.moniker) { + return unbonded.description.moniker; + } + + return 'Unknown'; + })(); + + return ( + } + chainId={msg.chainId} + title="Stake" + paragraph={`To ${moniker}`} + amount={amountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const DelegateIcon: FunctionComponent = ({size}) => { + return ( + + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-receive.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-receive.tsx new file mode 100644 index 0000000000..30badd9265 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-receive.tsx @@ -0,0 +1,80 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Buffer} from 'buffer'; +import {Bech32Address} from '@keplr-wallet/cosmos'; +import {MsgItemBase} from './base.tsx'; +import {ArrowDownLeftIcon} from './receive.tsx'; +import {useStyle} from '../../../styles'; + +export const MsgRelationIBCSendReceive: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const receives = msg.meta['receives'] as string[]; + for (const receive of receives) { + if (isValidCoinStr(receive)) { + const coin = parseCoinStr(receive); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + const fromAddress = (() => { + if (!msg.ibcTracking) { + return 'Unknown'; + } + + try { + const packet = JSON.parse( + Buffer.from(msg.ibcTracking.originPacket, 'base64').toString(), + ); + + return Bech32Address.shortenAddress(packet['sender'], 20); + } catch (e) { + console.log(e); + return 'Unknown'; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Receive" + paragraph={`From ${fromAddress}`} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'green', + prefix: 'plus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-send-refunded.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-send-refunded.tsx new file mode 100644 index 0000000000..73885a3ec9 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-send-refunded.tsx @@ -0,0 +1,60 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {useStore} from '../../../stores'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {MsgItemBase} from './base.tsx'; +import {ArrowDownLeftIcon} from './receive.tsx'; +import {useStyle} from '../../../styles'; +import {MsgHistory} from '../types.ts'; + +export const MsgRelationIBCSendRefunded: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const receives = msg.meta['receives'] as string[]; + for (const receive of receives) { + if (isValidCoinStr(receive)) { + const coin = parseCoinStr(receive); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + return ( + + } + chainId={msg.chainId} + title="Send Reverted" + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'none', + prefix: 'plus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-send.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-send.tsx new file mode 100644 index 0000000000..2d6a0e0a80 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-send.tsx @@ -0,0 +1,91 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Bech32Address} from '@keplr-wallet/cosmos'; +import {Buffer} from 'buffer'; +import {MsgItemBase} from './base.tsx'; +import {ArrowUpRightIcon} from './send.tsx'; +import {useStyle} from '../../../styles'; + +export const MsgRelationIBCSend: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const token = (msg.msg as any)['token'] as { + denom: string; + amount: string; + }; + + if (token.denom !== targetDenom) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, token.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const toAddress = (() => { + if (!msg.ibcTracking) { + return 'Unknown'; + } + + try { + let res = Bech32Address.shortenAddress((msg.msg as any)['receiver'], 20); + const packetData = Buffer.from( + msg.ibcTracking.originPacket, + 'base64', + ).toString(); + const parsed = JSON.parse(packetData); + let obj: any = (() => { + if (!parsed.memo) { + return undefined; + } + + typeof parsed.memo === 'string' ? JSON.parse(parsed.memo) : parsed.memo; + })(); + + while (obj) { + if (obj.receiver) { + res = Bech32Address.shortenAddress(obj.receiver, 20); + } + obj = typeof obj.next === 'string' ? JSON.parse(obj.next) : obj.next; + } + + return res; + } catch (e) { + console.log(e); + return 'Unknown'; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Send" + paragraph={`To ${toAddress}`} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + prefix: 'minus', + color: 'none', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-swap-receive.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-swap-receive.tsx new file mode 100644 index 0000000000..9d3a608bea --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-swap-receive.tsx @@ -0,0 +1,239 @@ +import {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {useStyle} from '../../../styles'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {ChainInfo} from '@keplr-wallet/types'; +import {Buffer} from 'buffer'; +import {MsgItemBase} from './base.tsx'; +import {ArrowRightLeftIcon} from './ibc-swap.tsx'; + +export const MsgRelationIBCSwapReceive: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore, queriesStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + const osmosisChainInfo = chainStore.getChain('osmosis'); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const receives = msg.meta['receives']; + if ( + receives && + Array.isArray(receives) && + receives.length > 0 && + typeof receives[0] === 'string' + ) { + for (const coinStr of receives) { + if (isValidCoinStr(coinStr as string)) { + const coin = parseCoinStr(coinStr as string); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + const sourceChain: ChainInfo | undefined = (() => { + if (!msg.ibcTracking) { + return undefined; + } + + try { + for (const path of msg.ibcTracking.paths) { + if (!path.chainId) { + return undefined; + } + if (!chainStore.hasChain(path.chainId)) { + return undefined; + } + + if (!path.clientChainId) { + return undefined; + } + if (!chainStore.hasChain(path.clientChainId)) { + return undefined; + } + } + + if (msg.ibcTracking.paths.length > 0) { + const path = msg.ibcTracking.paths[0]; + if (!path.chainId) { + return undefined; + } + if (!chainStore.hasChain(path.chainId)) { + return undefined; + } + return chainStore.getChain(path.chainId); + } + + return undefined; + } catch (e) { + console.log(e); + return undefined; + } + })(); + + // XXX: queries store를 쓰게되면서 구조상 useMemo를 쓰기 어렵다... + const srcDenom: string | undefined = (() => { + try { + // osmosis가 그자체에서 swap/receive가 끝난 경우. + if ( + (msg.msg as any)['@type'] === '/cosmwasm.wasm.v1.MsgExecuteContract' + ) { + const operations = (msg.msg as any).msg?.swap_and_action?.user_swap + ?.swap_exact_asset_in?.operations; + if (operations && operations.length > 0) { + const minimalDenom = operations[0].denom_in; + const currency = chainInfo.findCurrency(minimalDenom); + if (currency) { + if ('originCurrency' in currency && currency.originCurrency) { + return currency.originCurrency.coinDenom; + } + return currency.coinDenom; + } + } + } + + if (!msg.ibcTracking) { + return undefined; + } + + const parsed = JSON.parse( + Buffer.from(msg.ibcTracking.originPacket, 'base64').toString(), + ); + + let obj: any = (() => { + if (!parsed.memo) { + return undefined; + } + + return typeof parsed.memo === 'string' + ? JSON.parse(parsed.memo) + : parsed.memo; + })(); + + // 일단 대충 wasm 관련 msg를 찾는다. + // 어차피 지금은 osmosis 밖에 지원 안하니까 대충 osmosis에서 실행된다고 가정하면 된다. + while (obj) { + if ( + obj.forward && + obj.forward.port && + obj.forward.channel && + typeof obj.forward.port === 'string' && + typeof obj.forward.channel === 'string' + ) { + obj = typeof obj.next === 'string' ? JSON.parse(obj.next) : obj.next; + } else if ( + obj.wasm?.msg?.swap_and_action?.user_swap?.swap_exact_asset_in + ?.operations + ) { + const operations = + obj.wasm.msg.swap_and_action?.user_swap?.swap_exact_asset_in + .operations; + + if (operations && operations.length > 0) { + const minimalDenom = operations[0].denom_in; + const currency = osmosisChainInfo.findCurrency(minimalDenom); + if (currency) { + if ('originCurrency' in currency && currency.originCurrency) { + return currency.originCurrency.coinDenom; + } + return currency.coinDenom; + } + } + + obj = typeof obj.next === 'string' ? JSON.parse(obj.next) : obj.next; + break; + } else { + break; + } + } + + // 위에서 wasm 관련된 msg를 찾으면 바로 return되기 때문에 + // 여기까지 왔으면 wasm 관련된 msg를 못찾은거다 + // 이 경우는 osmosis에서 바로 swap되고 transfer된 경우다... + // 여기서 osmosis 위의 currency를 찾아야 하는데 + // 어차피 osmosis밖에 지원 안하므로 첫 지점은 무조건 osmosis다... + // 문제는 packet은 receive에 대한 정보만 주기 때문에 찾을수가 없다...;; + // 따로 query를 사용해서 origin message를 찾는수밖에 없다; + const queryOriginMsg = queriesStore.simpleQuery.queryGet( + process.env['KEPLR_EXT_TX_HISTORY_BASE_URL'] || '', + `/block/msg/${msg.ibcTracking.chainIdentifier}/${Buffer.from( + msg.ibcTracking.txHash, + 'base64', + ).toString('hex')}/${msg.ibcTracking.msgIndex}`, + ); + if (queryOriginMsg.response) { + const originMsg = queryOriginMsg.response.data as any; + if ( + originMsg?.msg?.swap_and_action?.user_swap?.swap_exact_asset_in + ?.operations + ) { + const operations = + originMsg.msg.swap_and_action?.user_swap?.swap_exact_asset_in + .operations; + if (operations && operations.length > 0) { + const minimalDenom = operations[0].denom_in; + const currency = osmosisChainInfo.findCurrency(minimalDenom); + if (currency) { + if ('originCurrency' in currency && currency.originCurrency) { + return currency.originCurrency.coinDenom; + } + return currency.coinDenom; + } + } + } + } + } catch (e) { + console.log(e); + return undefined; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Swap Completed" + paragraph={(() => { + if (srcDenom) { + if (!msg.ibcTracking) { + return `From ${srcDenom} on ${chainInfo.chainName}`; + } + + if (sourceChain) { + return `From ${srcDenom} on ${sourceChain.chainName}`; + } + } + return 'Unknown'; + })()} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'green', + prefix: 'plus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-swap-refunded.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-swap-refunded.tsx new file mode 100644 index 0000000000..4f291577db --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-swap-refunded.tsx @@ -0,0 +1,60 @@ +import {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {useStore} from '../../../stores'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {MsgItemBase} from './base.tsx'; +import {ArrowDownLeftIcon} from './receive.tsx'; +import {useStyle} from '../../../styles'; +import {MsgHistory} from '../types.ts'; + +export const MsgRelationIBCSwapRefunded: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const receives = msg.meta['receives'] as string[]; + for (const receive of receives) { + if (isValidCoinStr(receive)) { + const coin = parseCoinStr(receive); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + return ( + + } + chainId={msg.chainId} + title="Swap Refunded" + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'none', + prefix: 'plus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/ibc-swap.tsx b/apps/mobile/src/screen/activities/msg-items/ibc-swap.tsx new file mode 100644 index 0000000000..93a3e92e32 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/ibc-swap.tsx @@ -0,0 +1,211 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {ChainInfo} from '@keplr-wallet/types'; +import {Buffer} from 'buffer'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; +import {useStyle} from '../../../styles'; + +export const MsgRelationIBCSwap: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + const osmosisChainInfo = chainStore.getChain('osmosis'); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const from = msg.meta['from']; + if ( + from && + Array.isArray(from) && + from.length > 0 && + typeof from[0] === 'string' + ) { + for (const coinStr of from) { + if (isValidCoinStr(coinStr as string)) { + const coin = parseCoinStr(coinStr as string); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + const destinationChain: ChainInfo | undefined = (() => { + if (!msg.ibcTracking) { + return undefined; + } + + try { + let res: ChainInfo | undefined; + for (const path of msg.ibcTracking.paths) { + if (!path.chainId) { + return undefined; + } + if (!chainStore.hasChain(path.chainId)) { + return undefined; + } + + if (!path.clientChainId) { + return undefined; + } + if (!chainStore.hasChain(path.clientChainId)) { + return undefined; + } + + res = chainStore.getChain(path.clientChainId); + } + + return res; + } catch (e) { + console.log(e); + return undefined; + } + })(); + + const destDenom: string | undefined = (() => { + try { + // osmosis가 시작 지점일 경우. + if ( + (msg.msg as any)['@type'] === '/cosmwasm.wasm.v1.MsgExecuteContract' + ) { + const operations = (msg.msg as any).msg?.swap_and_action?.user_swap + ?.swap_exact_asset_in?.operations; + if (operations && operations.length > 0) { + const minimalDenom = operations[operations.length - 1].denom_out; + const currency = chainInfo.findCurrency(minimalDenom); + if (currency) { + if ('originCurrency' in currency && currency.originCurrency) { + return currency.originCurrency.coinDenom; + } + return currency.coinDenom; + } + } + } + + if (!msg.ibcTracking) { + return undefined; + } + + const parsed = JSON.parse( + Buffer.from(msg.ibcTracking.originPacket, 'base64').toString(), + ); + let obj: any = (() => { + if (!parsed.memo) { + return undefined; + } + + return typeof parsed.memo === 'string' + ? JSON.parse(parsed.memo) + : parsed.memo; + })(); + + // 일단 대충 wasm 관련 msg를 찾는다. + // 어차피 지금은 osmosis 밖에 지원 안하니까 대충 osmosis에서 실행된다고 가정하면 된다. + while (obj) { + if ( + obj.forward && + obj.forward.port && + obj.forward.channel && + typeof obj.forward.port === 'string' && + typeof obj.forward.channel === 'string' + ) { + obj = typeof obj.next === 'string' ? JSON.parse(obj.next) : obj.next; + } else if ( + obj.wasm?.msg?.swap_and_action?.user_swap?.swap_exact_asset_in + ?.operations + ) { + const operations = + obj.wasm.msg.swap_and_action?.user_swap?.swap_exact_asset_in + .operations; + + if (operations && operations.length > 0) { + const minimalDenom = operations[operations.length - 1].denom_out; + const currency = osmosisChainInfo.findCurrency(minimalDenom); + if (currency) { + if ('originCurrency' in currency && currency.originCurrency) { + return currency.originCurrency.coinDenom; + } + return currency.coinDenom; + } + } + + obj = typeof obj.next === 'string' ? JSON.parse(obj.next) : obj.next; + break; + } else { + break; + } + } + } catch (e) { + console.log(e); + return undefined; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Swap" + paragraph={(() => { + if (destDenom) { + if (!msg.ibcTracking) { + return `To ${destDenom} on ${chainInfo.chainName}`; + } + + if (destinationChain) { + return `To ${destDenom} on ${destinationChain.chainName}`; + } + } + return 'Unknown'; + })()} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'none', + prefix: 'minus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const ArrowRightLeftIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/index.tsx b/apps/mobile/src/screen/activities/msg-items/index.tsx new file mode 100644 index 0000000000..6a18e3bf4b --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/index.tsx @@ -0,0 +1,258 @@ +import React, {ErrorInfo, FunctionComponent, PropsWithChildren} from 'react'; +import {MsgHistory} from '../types.ts'; +import {Box} from '../../../components/box'; +import {Text} from 'react-native'; +import {MsgRelationSend} from './send.tsx'; +import {MsgRelationReceive} from './receive.tsx'; +import {MsgRelationIBCSend} from './ibc-send.tsx'; +import {MsgRelationIBCSendReceive} from './ibc-receive.tsx'; +import {MsgRelationIBCSendRefunded} from './ibc-send-refunded.tsx'; +import {MsgRelationIBCSwap} from './ibc-swap.tsx'; +import {MsgRelationIBCSwapReceive} from './ibc-swap-receive.tsx'; +import {MsgRelationIBCSwapRefunded} from './ibc-swap-refunded.tsx'; +import {MsgRelationDelegate} from './delegate.tsx'; +import {MsgRelationUndelegate} from './undelegate.tsx'; +import {MsgRelationRedelegate} from './redelegate.tsx'; +import {MsgRelationCancelUndelegate} from './cancel-undelegate.tsx'; +import {MsgRelationVote} from './vote.tsx'; +import {MsgRelationMergedClaimRewards} from './merged-claim-rewards.tsx'; +import {XAxis} from '../../../components/axis'; +import {ItemLogo} from './logo.tsx'; +import {Gutter} from '../../../components/gutter'; +import {useStyle} from '../../../styles'; +import Svg, {Path} from 'react-native-svg'; + +export const MsgItemRender: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage?: boolean; +}> = ({msg, prices, targetDenom, isInAllActivitiesPage}) => { + return ( + + + + ); +}; + +const MsgItemRenderInner: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage?: boolean; +}> = ({msg, prices, targetDenom, isInAllActivitiesPage}) => { + switch (msg.relation) { + case 'send': { + return ( + + ); + } + case 'receive': { + return ( + + ); + } + case 'ibc-send': { + return ( + + ); + } + case 'ibc-send-receive': { + return ( + + ); + } + case 'ibc-send-refunded': { + return ( + + ); + } + case 'ibc-swap-skip-osmosis': { + return ( + + ); + } + case 'ibc-swap-skip-osmosis-receive': { + return ( + + ); + } + case 'ibc-swap-skip-osmosis-refunded': { + return ( + + ); + } + case 'delegate': { + return ( + + ); + } + case 'undelegate': { + return ( + + ); + } + case 'redelegate': { + return ( + + ); + } + case 'cancel-undelegate': { + return ( + + ); + } + case 'vote': { + return ( + + ); + } + case 'custom/merged-claim-rewards': { + return ( + + ); + } + } + + return ; +}; + +const UnknownMsgItem: FunctionComponent<{ + title: string; +}> = ({title}) => { + const style = useStyle(); + return ( + + + } /> + + + + + {title} + + + + + + ); +}; + +const UnknownIcon: FunctionComponent = () => { + return ( + + + + ); +}; + +class ErrorBoundary extends React.Component< + PropsWithChildren, + { + hasError: boolean; + } +> { + constructor(props: PropsWithChildren) { + super(props); + this.state = {hasError: false}; + } + + static getDerivedStateFromError() { + return {hasError: true}; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.log(error, errorInfo); + } + + override render() { + if (this.state.hasError) { + return ; + } + + return this.props.children; + } +} diff --git a/apps/mobile/src/screen/activities/msg-items/logo.tsx b/apps/mobile/src/screen/activities/msg-items/logo.tsx new file mode 100644 index 0000000000..f55061f78e --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/logo.tsx @@ -0,0 +1,22 @@ +import React, {FunctionComponent} from 'react'; +import {useStyle} from '../../../styles'; +import {Box} from '../../../components/box'; + +export const ItemLogo: FunctionComponent<{ + center: React.ReactElement; + backgroundColor?: string; +}> = ({center, backgroundColor}) => { + const style = useStyle(); + + return ( + + {center} + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/merged-claim-rewards.tsx b/apps/mobile/src/screen/activities/msg-items/merged-claim-rewards.tsx new file mode 100644 index 0000000000..6f1135b742 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/merged-claim-rewards.tsx @@ -0,0 +1,253 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {ColorPalette, useStyle} from '../../../styles'; +import {isValidCoinStr, parseCoinStr} from '@keplr-wallet/common'; +import {CoinPretty, Dec, PricePretty} from '@keplr-wallet/unit'; +import {AppCurrency} from '@keplr-wallet/types'; +import {MsgItemBase} from './base.tsx'; +import {Path, Svg} from 'react-native-svg'; +import {Box} from '../../../components/box'; +import {ArrowDownIcon} from '../../../components/icon/arrow-down.tsx'; +import {ArrowUpIcon} from '../../../components/icon/arrow-up.tsx'; +import {TouchableWithoutFeedback} from 'react-native-gesture-handler'; +import {RectButton} from '../../../components/rect-button'; +import {XAxis, YAxis} from '../../../components/axis'; +import {Text} from 'react-native'; +import {Gutter} from '../../../components/gutter'; +import {VerticalCollapseTransition} from '../../../components/transition'; +import {ItemLogo} from './logo.tsx'; + +export const MsgRelationMergedClaimRewards: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const amountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const rewards = msg.meta['rewards']; + if ( + rewards && + Array.isArray(rewards) && + rewards.length > 0 && + typeof rewards[0] === 'string' + ) { + for (const coinStr of rewards) { + if (isValidCoinStr(coinStr as string)) { + const coin = parseCoinStr(coinStr as string); + if (coin.denom === targetDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + } + + return new CoinPretty(currency, '0'); + }, [chainInfo, msg.meta, targetDenom]); + + const otherKnownCurrencies = (() => { + const res: AppCurrency[] = []; + if (msg.denoms) { + for (const denom of msg.denoms) { + if (denom !== targetDenom) { + const currency = chainInfo.findCurrency(denom); + if (currency) { + if ( + currency.coinMinimalDenom.startsWith('ibc/') && + (!('originCurrency' in currency) || !currency.originCurrency) + ) { + continue; + } + res.push(currency); + } + } + } + } + return res; + })(); + + return ( + } + chainId={msg.chainId} + title="Claim Reward" + amount={amountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'green', + prefix: 'plus', + }} + bottom={(() => { + if (isInAllActivitiesPage) { + if (msg.code === 0 && otherKnownCurrencies.length > 0) { + return ( + + ); + } + } + })()} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const CheckIcon: FunctionComponent = () => { + return ( + + + + ); +}; + +const BottomExpandableOtherRewarsOnAllActivitiesPage: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + currencies: AppCurrency[]; +}> = observer(({msg, prices, currencies}) => { + const {priceStore} = useStore(); + + const style = useStyle(); + + const defaultVsCurrency = priceStore.defaultVsCurrency; + + const [isCollapsed, setIsCollapsed] = React.useState(true); + + return ( + + + {currencies.map(currency => { + const amountPretty = (() => { + const rewards = msg.meta['rewards']; + if ( + rewards && + Array.isArray(rewards) && + rewards.length > 0 && + typeof rewards[0] === 'string' + ) { + for (const coinStr of rewards) { + if (isValidCoinStr(coinStr as string)) { + const coin = parseCoinStr(coinStr as string); + if (coin.denom === currency.coinMinimalDenom) { + return new CoinPretty(currency, coin.amount); + } + } + } + } + + return new CoinPretty(currency, '0'); + })(); + + const sendAmountPricePretty = (() => { + if (currency && currency.coinGeckoId) { + const price = (prices || {})[currency.coinGeckoId]; + if (price != null && price[defaultVsCurrency] != null) { + const dec = amountPretty.toDec(); + const priceDec = new Dec(price[defaultVsCurrency]!.toString()); + const fiatCurrency = + priceStore.getFiatCurrency(defaultVsCurrency); + if (fiatCurrency) { + return new PricePretty(fiatCurrency, dec.mul(priceDec)); + } + } + } + return; + })(); + + return ( + + + } /> + + + + + Claim Reward + + + + + + + + {(() => { + return ( + + + {amountPretty + .maxDecimals(2) + .shrink(true) + .hideIBCMetadata(true) + .inequalitySymbol(true) + .inequalitySymbolSeparator('') + .toString()} + + {sendAmountPricePretty ? ( + + + + {sendAmountPricePretty.toString()} + + + ) : null} + + ); + })()} + + + + ); + })} + + { + setIsCollapsed(!isCollapsed); + }}> + setIsCollapsed(!isCollapsed)}> + + + {isCollapsed ? 'Expand' : 'Collapse'} + + + + + {isCollapsed ? ( + + ) : ( + + )} + + + + + ); +}); diff --git a/apps/mobile/src/screen/activities/msg-items/receive.tsx b/apps/mobile/src/screen/activities/msg-items/receive.tsx new file mode 100644 index 0000000000..4dc71cf3cc --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/receive.tsx @@ -0,0 +1,87 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgItemBase} from './base.tsx'; +import {useStore} from '../../../stores'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; +import {useStyle} from '../../../styles'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Bech32Address} from '@keplr-wallet/cosmos'; +import {MsgHistory} from '../types.ts'; + +export const MsgRelationReceive: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amounts = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }[]; + + const amt = amounts.find(amt => amt.denom === targetDenom); + if (!amt) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amt.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const fromAddress = (() => { + try { + return Bech32Address.shortenAddress((msg.msg as any)['from_address'], 20); + } catch (e) { + console.log(e); + return 'Unknown'; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Receive" + paragraph={`From ${fromAddress}`} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + color: 'green', + prefix: 'plus', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const ArrowDownLeftIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/redelegate.tsx b/apps/mobile/src/screen/activities/msg-items/redelegate.tsx new file mode 100644 index 0000000000..784d421e0c --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/redelegate.tsx @@ -0,0 +1,129 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {useStore} from '../../../stores'; +import {MsgHistory} from '../types.ts'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Staking} from '@keplr-wallet/stores'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; +import {useStyle} from '../../../styles'; + +export const MsgRelationRedelegate: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore, queriesStore} = useStore(); + + const style = useStyle(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const amountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amount = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }; + + if (amount.denom !== targetDenom) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amount.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const srcValidatorAddress: string = useMemo(() => { + return (msg.msg as any)['validator_src_address']; + }, [msg.msg]); + const dstValidatorAddress: string = useMemo(() => { + return (msg.msg as any)['validator_dst_address']; + }, [msg.msg]); + + const queryBonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Bonded); + const queryUnbonding = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonding); + const queryUnbonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonded); + + const srcMoniker: string = (() => { + if (!srcValidatorAddress) { + return 'Unknown'; + } + const bonded = queryBonded.getValidator(srcValidatorAddress); + if (bonded?.description.moniker) { + return bonded.description.moniker; + } + const unbonding = queryUnbonding.getValidator(srcValidatorAddress); + if (unbonding?.description.moniker) { + return unbonding.description.moniker; + } + const unbonded = queryUnbonded.getValidator(srcValidatorAddress); + if (unbonded?.description.moniker) { + return unbonded.description.moniker; + } + + return 'Unknown'; + })(); + + const dstMoniker: string = (() => { + if (!dstValidatorAddress) { + return 'Unknown'; + } + const bonded = queryBonded.getValidator(dstValidatorAddress); + if (bonded?.description.moniker) { + return bonded.description.moniker; + } + const unbonding = queryUnbonding.getValidator(dstValidatorAddress); + if (unbonding?.description.moniker) { + return unbonding.description.moniker; + } + const unbonded = queryUnbonded.getValidator(dstValidatorAddress); + if (unbonded?.description.moniker) { + return unbonded.description.moniker; + } + + return 'Unknown'; + })(); + + return ( + + } + chainId={msg.chainId} + title="Switch Validator" + paragraph={`${(() => { + if (srcMoniker.length + dstMoniker.length > 18) { + return `${srcMoniker.slice(0, 9)}...`; + } + return srcMoniker; + })()} -> ${dstMoniker}`} + amount={amountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const RedelegateIcon: FunctionComponent = ({size, color}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/send.tsx b/apps/mobile/src/screen/activities/msg-items/send.tsx new file mode 100644 index 0000000000..bf0e06d5ab --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/send.tsx @@ -0,0 +1,84 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Bech32Address} from '@keplr-wallet/cosmos'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; +import {useStyle} from '../../../styles'; + +export const MsgRelationSend: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const style = useStyle(); + + const {chainStore} = useStore(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const sendAmountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amounts = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }[]; + + const amt = amounts.find(amt => amt.denom === targetDenom); + if (!amt) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amt.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const toAddress = (() => { + try { + return Bech32Address.shortenAddress((msg.msg as any)['to_address'], 20); + } catch (e) { + console.log(e); + return 'Unknown'; + } + })(); + + return ( + + } + chainId={msg.chainId} + title="Send" + paragraph={`To ${toAddress}`} + amount={sendAmountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + amountDeco={{ + prefix: 'minus', + color: 'none', + }} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const ArrowUpRightIcon: FunctionComponent = ({ + size, + color, +}) => { + return ( + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/skeleton.tsx b/apps/mobile/src/screen/activities/msg-items/skeleton.tsx new file mode 100644 index 0000000000..4bb9b751a0 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/skeleton.tsx @@ -0,0 +1,61 @@ +import React, {FunctionComponent} from 'react'; +import {Box} from '../../../components/box'; +import {useStyle} from '../../../styles'; +import {XAxis, YAxis} from '../../../components/axis'; +import {Gutter} from '../../../components/gutter'; + +export const MsgItemSkeleton: FunctionComponent = () => { + const style = useStyle(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/undelegate.tsx b/apps/mobile/src/screen/activities/msg-items/undelegate.tsx new file mode 100644 index 0000000000..3e89778aa5 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/undelegate.tsx @@ -0,0 +1,98 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {useStore} from '../../../stores'; +import {CoinPretty} from '@keplr-wallet/unit'; +import {Staking} from '@keplr-wallet/stores'; +import {MsgItemBase} from './base.tsx'; +import {IconProps} from '../../../components/icon/types.ts'; +import {Path, Svg} from 'react-native-svg'; + +export const MsgRelationUndelegate: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const {chainStore, queriesStore} = useStore(); + + const chainInfo = chainStore.getChain(msg.chainId); + + const amountPretty = useMemo(() => { + const currency = chainInfo.forceFindCurrency(targetDenom); + + const amount = (msg.msg as any)['amount'] as { + denom: string; + amount: string; + }; + + if (amount.denom !== targetDenom) { + return new CoinPretty(currency, '0'); + } + return new CoinPretty(currency, amount.amount); + }, [chainInfo, msg.msg, targetDenom]); + + const validatorAddress: string = useMemo(() => { + return (msg.msg as any)['validator_address']; + }, [msg.msg]); + + const queryBonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Bonded); + const queryUnbonding = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonding); + const queryUnbonded = queriesStore + .get(chainInfo.chainId) + .cosmos.queryValidators.getQueryStatus(Staking.BondStatus.Unbonded); + + const moniker: string = (() => { + if (!validatorAddress) { + return 'Unknown'; + } + const bonded = queryBonded.getValidator(validatorAddress); + if (bonded?.description.moniker) { + return bonded.description.moniker; + } + const unbonding = queryUnbonding.getValidator(validatorAddress); + if (unbonding?.description.moniker) { + return unbonding.description.moniker; + } + const unbonded = queryUnbonded.getValidator(validatorAddress); + if (unbonded?.description.moniker) { + return unbonded.description.moniker; + } + + return 'Unknown'; + })(); + + return ( + } + chainId={msg.chainId} + title="Unstake" + paragraph={`From ${moniker}`} + amount={amountPretty} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const UndelegateIcon: FunctionComponent = ({size}) => { + return ( + + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/msg-items/vote.tsx b/apps/mobile/src/screen/activities/msg-items/vote.tsx new file mode 100644 index 0000000000..3d16026e45 --- /dev/null +++ b/apps/mobile/src/screen/activities/msg-items/vote.tsx @@ -0,0 +1,115 @@ +import React, {FunctionComponent, useMemo} from 'react'; +import {observer} from 'mobx-react-lite'; +import {MsgHistory} from '../types.ts'; +import {MsgItemBase} from './base.tsx'; +import {G, Defs, Path, Rect, Svg, ClipPath} from 'react-native-svg'; +import {useStyle} from '../../../styles'; + +export const MsgRelationVote: FunctionComponent<{ + msg: MsgHistory; + prices?: Record | undefined>; + targetDenom: string; + isInAllActivitiesPage: boolean | undefined; +}> = observer(({msg, prices, targetDenom, isInAllActivitiesPage}) => { + const style = useStyle(); + const proposal: { + proposalId: string; + } = useMemo(() => { + return { + proposalId: (msg.msg as any)['proposal_id'], + }; + }, [msg.msg]); + + const voteText: { + text: string; + color: string; + } = useMemo(() => { + switch ((msg.msg as any)['option']) { + case 'VOTE_OPTION_YES': + return { + text: 'Yes', + color: style.get('color-gray-10').color, + }; + case 'VOTE_OPTION_NO': + return { + text: 'No', + color: style.get('color-gray-10').color, + }; + case 'VOTE_OPTION_NO_WITH_VETO': + return { + text: 'NWV', + color: style.get('color-yellow-400').color, + }; + case 'VOTE_OPTION_ABSTAIN': + return { + text: 'Abstain', + color: style.get('color-gray-10').color, + }; + default: + return { + text: 'Unknown', + color: style.get('color-gray-10').color, + }; + } + }, [msg.msg]); + + return ( + } + chainId={msg.chainId} + title="Vote" + paragraph={`#${proposal.proposalId}`} + amount={voteText.text} + overrideAmountColor={voteText.color} + prices={prices || {}} + msg={msg} + targetDenom={targetDenom} + isInAllActivitiesPage={isInAllActivitiesPage} + /> + ); +}); + +export const VoteIcon: FunctionComponent = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/apps/mobile/src/screen/activities/types.ts b/apps/mobile/src/screen/activities/types.ts new file mode 100644 index 0000000000..11abc33342 --- /dev/null +++ b/apps/mobile/src/screen/activities/types.ts @@ -0,0 +1,57 @@ +export interface ResMsgsHistory { + msgs: { + msg: MsgHistory; + prices?: Record | undefined>; + }[]; + nextCursor: string; + isUnsupported?: boolean; +} + +export interface MsgHistory { + txHash: string; + code: number; + + height: number; + time: string; + chainId: string; + chainIdentifier: string; + + relation: string; + msgIndex: number; + msg: unknown; + eventStartIndex: number; + eventEndIndex: number; + + search: string; + denoms?: string[]; + meta: Record; + + ibcTracking?: { + chainId: string; + chainIdentifier: string; + txHeight: number; + txHash: string; + msgIndex: number; + originPortId: string; + originChannelId: string; + originSequence: number; + paths: { + status: 'pending' | 'success' | 'refunded' | 'failed' | 'unknown-result'; + chainId?: string; + chainIdentifier?: string; + portId: string; + channelId: string; + sequence?: number; + + counterpartyChannelId?: string; + counterpartyPortId?: string; + clientChainId?: string; + clientChainIdentifier?: string; + + clientFetched: boolean; + }[]; + + // base64 encoded + originPacket: string; + }; +}