diff --git a/package.json b/package.json index 1f1541866b..2cf5d91047 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "dns.js": "^1.0.1", "domain-browser": "^1.1.1", "events": "^1.0.0", + "hive-uri": "^0.2.4", "hivesigner": "^3.2.7", "https-browserify": "~0.0.0", "intl": "^1.2.5", diff --git a/src/components/actionModal/container/actionModalContainer.tsx b/src/components/actionModal/container/actionModalContainer.tsx index ebb9a28253..c428e01653 100644 --- a/src/components/actionModal/container/actionModalContainer.tsx +++ b/src/components/actionModal/container/actionModalContainer.tsx @@ -17,6 +17,7 @@ export interface ActionModalData { headerImage?: Source; onClosed: () => void; headerContent?: React.ReactNode; + bodyContent?: React.ReactNode; } const ActionModalContainer = ({ navigation }) => { diff --git a/src/components/actionModal/view/actionModalView.tsx b/src/components/actionModal/view/actionModalView.tsx index d130b1e6c5..e20d96be5c 100644 --- a/src/components/actionModal/view/actionModalView.tsx +++ b/src/components/actionModal/view/actionModalView.tsx @@ -38,7 +38,7 @@ const ActionModalView = ({ onClose, data }: ActionModalViewProps, ref) => { return null; } - const { title, body, buttons, headerImage, para, headerContent } = data; + const { title, body, buttons, headerImage, para, headerContent, bodyContent } = data; const _renderContent = ( @@ -49,6 +49,7 @@ const ActionModalView = ({ onClose, data }: ActionModalViewProps, ref) => { {title} + {bodyContent && bodyContent} {!!body && ( <> {body} diff --git a/src/components/index.tsx b/src/components/index.tsx index 6d7618936f..05eb5b7905 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -102,6 +102,7 @@ import BeneficiarySelectionContent from './beneficiarySelectionContent/beneficia import TransferAccountSelector from './transferAccountSelector/transferAccountSelector'; import TransferAmountInputSection from './transferAmountInputSection/transferAmountInputSection'; import TextBoxWithCopy from './textBoxWithCopy/textBoxWithCopy'; +import WebViewModal from './webViewModal/webViewModal'; // Basic UI Elements import { @@ -251,4 +252,5 @@ export { TransferAccountSelector, TransferAmountInputSection, TextBoxWithCopy, + WebViewModal, }; diff --git a/src/components/qrModal/qrModalStyles.ts b/src/components/qrModal/qrModalStyles.ts index 72fb12026c..5ce8675faa 100644 --- a/src/components/qrModal/qrModalStyles.ts +++ b/src/components/qrModal/qrModalStyles.ts @@ -1,4 +1,7 @@ +import { TextStyle, ViewStyle } from 'react-native'; import EStyleSheet from 'react-native-extended-stylesheet'; +import getWindowDimensions from '../../utils/getWindowDimensions'; +const { width: SCREEN_WIDTH } = getWindowDimensions(); export default EStyleSheet.create({ sheetContent: { @@ -36,4 +39,46 @@ export default EStyleSheet.create({ alignItems: 'center', }, activityIndicator: {}, + transactionBodyContainer: { + borderWidth: 1, + borderColor: '$borderColor', + borderRadius: 8, + // padding: 8, + marginVertical: 10, + width: SCREEN_WIDTH - 64, + } as ViewStyle, + transactionRow: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4, + } as ViewStyle, + transactionHeadingContainer: { + borderBottomWidth: 1, + borderColor: '$borderColor', + height: 36, + paddingHorizontal: 8, + justifyContent: 'center', + } as ViewStyle, + transactionHeading: { + color: '$primaryBlack', + fontSize: 18, + fontWeight: '700', + textTransform: 'capitalize', + } as TextStyle, + transactionItemsContainer: { + padding: 8, + } as ViewStyle, + transactionItem1: { + color: '$primaryBlack', + fontSize: 16, + fontWeight: '700', + flex: 1, + textTransform: 'capitalize', + } as TextStyle, + transactionItem2: { + color: '$primaryBlack', + fontSize: 16, + fontWeight: '400', + flex: 1, + } as TextStyle, }); diff --git a/src/components/qrModal/qrModalView.tsx b/src/components/qrModal/qrModalView.tsx index de51ec5808..31b48be4e1 100644 --- a/src/components/qrModal/qrModalView.tsx +++ b/src/components/qrModal/qrModalView.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, Alert, PermissionsAndroid, Platform, Text, View } from 'react-native'; +import { ActivityIndicator, Alert, PermissionsAndroid, Platform, View, Text } from 'react-native'; import ActionSheet from 'react-native-actions-sheet'; import EStyleSheet from 'react-native-extended-stylesheet'; import QRCodeScanner from 'react-native-qrcode-scanner'; @@ -7,19 +7,36 @@ import { useIntl } from 'react-intl'; import { check, request, PERMISSIONS, RESULTS, openSettings } from 'react-native-permissions'; import styles from './qrModalStyles'; import { useAppDispatch, useAppSelector } from '../../hooks'; -import { toggleQRModal } from '../../redux/actions/uiAction'; +import { + showActionModal, + showWebViewModal, + toastNotification, + toggleQRModal, +} from '../../redux/actions/uiAction'; import { deepLinkParser } from '../../utils/deepLinkParser'; import RootNavigation from '../../navigation/rootNavigation'; import getWindowDimensions from '../../utils/getWindowDimensions'; +import { isHiveUri } from '../../utils/hive-uri'; +import { handleHiveUriOperation } from '../../providers/hive/dhive'; +import bugsnagInstance from '../../config/bugsnag'; +import { get, isArray } from 'lodash'; +import showLoginAlert from '../../utils/showLoginAlert'; +import authType from '../../constants/authType'; +import { delay } from '../../utils/editor'; +import ROUTES from '../../../src/constants/routeNames'; -export interface QRModalProps {} - +const hiveuri = require('hive-uri'); const screenHeight = getWindowDimensions().height; +interface QRModalProps {} + export const QRModal = ({}: QRModalProps) => { const dispatch = useAppDispatch(); const intl = useIntl(); const isVisibleQRModal = useAppSelector((state) => state.ui.isVisibleQRModal); const currentAccount = useAppSelector((state) => state.account.currentAccount); + const pinCode = useAppSelector((state) => state.application.pin); + const isPinCodeOpen = useAppSelector((state) => state.application.isPinCodeOpen); + const isLoggedIn = useAppSelector((state) => state.application.isLoggedIn); const [isScannerActive, setIsScannerActive] = useState(true); const [isProcessing, setIsProcessing] = useState(false); @@ -29,9 +46,9 @@ export const QRModal = ({}: QRModalProps) => { useEffect(() => { if (isVisibleQRModal) { requestCameraPermission(); - sheetModalRef.current.show(); + sheetModalRef?.current?.show(); } else { - sheetModalRef.current.hide(); + sheetModalRef?.current?.hide(); } }, [isVisibleQRModal]); @@ -97,42 +114,161 @@ export const QRModal = ({}: QRModalProps) => { const onSuccess = (e) => { setIsScannerActive(false); - _handleDeepLink(e.data); + if (isHiveUri(e.data)) { + _handleHiveUri(e.data); + } else { + _handleDeepLink(e.data); + } }; - const _handleDeepLink = async (url) => { - setIsProcessing(true); - const deepLinkData = await deepLinkParser(url, currentAccount); - const { name, params, key } = deepLinkData || {}; - setIsProcessing(false); - if (name && params && key) { + const _handleHiveUri = async (uri: string) => { + try { setIsScannerActive(false); _onClose(); - RootNavigation.navigate(deepLinkData); - } else { + if (!isLoggedIn) { + showLoginAlert({ intl }); + return; + } + if (isPinCodeOpen) { + RootNavigation.navigate({ + name: ROUTES.SCREENS.PINCODE, + params: { + callback: () => _handleHiveUriTransaction(uri), + }, + }); + } else { + _handleHiveUriTransaction(uri); + } + } catch (err) { + _showInvalidAlert(); + } + }; + + const _handleHiveUriTransaction = async (uri: string) => { + if (get(currentAccount, 'local.authType') === authType.STEEM_CONNECT) { + await delay(500); // NOTE: it's required to avoid modal mis fire + dispatch( + showWebViewModal({ + uri: uri, + }), + ); + return; + } + + const parsed = hiveuri.decode(uri); + // resolve the decoded tx and params to a signable tx + let { tx, signer } = hiveuri.resolveTransaction(parsed.tx, parsed.params, { + signers: currentAccount.name, + preferred_signer: currentAccount.name, + }); + const operations = get(tx, 'operations', []); + if (!_checkOpsArray(operations)) { Alert.alert( - intl.formatMessage({ id: 'qr.unsupported_alert_title' }), - intl.formatMessage({ id: 'qr.unsupported_alert_desc' }), - [ + intl.formatMessage({ + id: 'qr.multi_array_ops_alert', + }), + intl.formatMessage({ + id: 'qr.multi_array_ops_aler_desct', + }), + ); + return; + } + dispatch( + showActionModal({ + title: intl.formatMessage({ + id: 'qr.confirmTransaction', + }), + bodyContent: _checkOpsArray(operations) ? _renderActionModalBody(operations[0]) : null, + buttons: [ { - text: 'Close', - onPress: () => { - _onClose(); - }, + text: intl.formatMessage({ + id: 'qr.cancel', + }), + onPress: () => {}, style: 'cancel', }, { - text: 'Rescan', + text: intl.formatMessage({ + id: 'qr.approve', + }), onPress: () => { - setIsScannerActive(true); - scannerRef.current?.reactivate(); + handleHiveUriOperation(currentAccount, pinCode, uri) + .then(() => { + dispatch(toastNotification(intl.formatMessage({ id: 'alert.successful' }))); + }) + .catch((err) => { + bugsnagInstance.notify(err); + dispatch(toastNotification(intl.formatMessage({ id: 'alert.key_warning' }))); + }); }, }, ], - ); + }), + ); + }; + + const _handleDeepLink = async (url) => { + setIsProcessing(true); + const deepLinkData = await deepLinkParser(url); + const { name, params, key } = deepLinkData || {}; + setIsProcessing(false); + if (name && params && key) { + setIsScannerActive(false); + _onClose(); + RootNavigation.navigate(deepLinkData); + } else { + _showInvalidAlert(); } }; + // check operation array is valid and is a single operation array + const _checkOpsArray = (ops) => { + return ops && isArray(ops) && ops.length === 1 && isArray(ops[0]) && ops[0].length === 2; + }; + + const _renderTransactionInfoRow = (item: any) => ( + + + {item[0]} + + + {item[1]} + + + ); + const _renderActionModalBody = (operations: any) => ( + + + {operations[0]} + + + {Object.entries(operations[1]).map((item) => _renderTransactionInfoRow(item))} + + + ); + const _showInvalidAlert = () => { + Alert.alert( + intl.formatMessage({ id: 'qr.unsupported_alert_title' }), + intl.formatMessage({ id: 'qr.unsupported_alert_desc' }), + [ + { + text: 'Close', + onPress: () => { + _onClose(); + }, + style: 'cancel', + }, + { + text: 'Rescan', + onPress: () => { + setIsScannerActive(true); + scannerRef.current?.reactivate(); + }, + }, + ], + ); + }; + return ( { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const isVisibleWebViewModal = useAppSelector((state) => state.ui.isVisibleWebViewModal); + const webViewModalData: WebViewModalData = useAppSelector((state) => state.ui.webViewModalData); + + const [hiveSignerModal, setHiveSignerModal] = useState(false); + + useEffect(() => { + if (isVisibleWebViewModal) { + setHiveSignerModal(true); + } else { + setHiveSignerModal(false); + } + }, [isVisibleWebViewModal]); + + const _onClose = () => { + dispatch(hideWebViewModal()); + }; + + return ( + + {webViewModalData && ( + + )} + + ); +}; + +export default WebViewModal; diff --git a/src/components/webViewModal/webViewModalStyles.ts b/src/components/webViewModal/webViewModalStyles.ts new file mode 100644 index 0000000000..200facecd2 --- /dev/null +++ b/src/components/webViewModal/webViewModalStyles.ts @@ -0,0 +1,8 @@ +import EStyleSheet from 'react-native-extended-stylesheet'; + +export default EStyleSheet.create({ + container: { + flex: 1, + backgroundColor: '$primaryBackgroundColor', + }, +}); diff --git a/src/config/locales/en-US.json b/src/config/locales/en-US.json index 5b25bc6735..a92d0f33ab 100644 --- a/src/config/locales/en-US.json +++ b/src/config/locales/en-US.json @@ -1035,7 +1035,12 @@ "open": "Open URL", "detected_url": "Detected URL", "unsupported_alert_title": "Unsupported URL!", - "unsupported_alert_desc": "Please scan a valid ecency url." + "unsupported_alert_desc": "Please scan a valid ecency url.", + "confirmTransaction": "Confirm transaction", + "approve": "Approve", + "cancel": "Cancel", + "multi_array_ops_alert": "Multiple operations array detected!", + "multi_array_ops_aler_desct": "Ecency does not support signing multiple operations, yet" }, "history": { "edit": "Edit History", diff --git a/src/providers/hive/dhive.js b/src/providers/hive/dhive.js index 872df91539..8ea88d7111 100644 --- a/src/providers/hive/dhive.js +++ b/src/providers/hive/dhive.js @@ -44,6 +44,7 @@ import bugsnagInstance from '../../config/bugsnag'; import bugsnapInstance from '../../config/bugsnag'; import { makeJsonMetadataReply } from '../../utils/editor'; +const hiveuri = require('hive-uri'); global.Buffer = global.Buffer || require('buffer').Buffer; const DEFAULT_SERVER = SERVER_LIST; @@ -2080,3 +2081,60 @@ export const votingPower = (account) => { return percentage / 100; }; /* eslint-enable */ + +export const handleHiveUriOperation = async ( + currentAccount: any, + pin: any, + hiveUri: string, +): Promise => { + try { + const digitPinCode = getDigitPinCode(pin); + const key = getAnyPrivateKey(currentAccount.local, digitPinCode); + const privateKey = PrivateKey.fromString(key); + + const { head_block_number, head_block_id, time } = await getDynamicGlobalProperties(); + const ref_block_num = head_block_number & 0xffff; + const ref_block_prefix = Buffer.from(head_block_id, 'hex').readUInt32LE(4); + const expireTime = 60 * 1000; + const chainId = Buffer.from( + 'beeab0de00000000000000000000000000000000000000000000000000000000', + 'hex', + ); + const expiration = new Date(new Date(`${time}Z`).getTime() + expireTime) + .toISOString() + .slice(0, -5); + const extensions = []; + + + const parsed = hiveuri.decode(hiveUri) + // resolve the decoded tx and params to a signable tx + let { tx, signer } = hiveuri.resolveTransaction(parsed.tx, parsed.params, { + + expiration, + // accounts we are able to sign for + signers: currentAccount.name, + // selected signer if none is asked for by the params + preferred_signer: currentAccount.name, + }); + + + //inject raw ref_block_num and ref_block_prefex to avoid string converstion by hiveuri.resolveTransaction + // e.g. from a get_dynamic_global_properties call + tx.ref_block_num = ref_block_num; + tx.ref_block_prefix = ref_block_prefix; + + // console.log('tx : ', JSON.stringify(tx, null, 2)); + const transaction = cryptoUtils.signTransaction(tx, privateKey, chainId); + const trxId = generateTrxId(transaction); + const resultHive = await client.broadcast.call('broadcast_transaction', [transaction]); + const result = Object.assign({ id: trxId }, resultHive); + // console.log('result : ', JSON.stringify(result, null, 2)); + return result; + } catch (err) { + bugsnagInstance.notify(err, (event) => { + event.context = 'handle-hive-uri-operations'; + event.setMetaData('hiveUri', hiveUri); + }); + throw err; + } +}; diff --git a/src/redux/actions/uiAction.ts b/src/redux/actions/uiAction.ts index 01cca10160..a4298513d9 100644 --- a/src/redux/actions/uiAction.ts +++ b/src/redux/actions/uiAction.ts @@ -15,6 +15,8 @@ import { SET_LOCKED_ORIENTATION, LOGOUT, LOGOUT_DONE, + SHOW_WEBVIEW_MODAL, + HIDE_WEBVIEW_MODAL, } from '../constants/constants'; import { PostEditorModalData } from '../reducers/uiReducer'; @@ -73,6 +75,20 @@ export const toggleQRModal = (payload: boolean) => ({ type: TOGGLE_QR_MODAL, }); +export const showWebViewModal = (payload: any) => ({ + payload: { + isVisibleWebViewModal: new Date().getTime(), + webViewModalData: { + ...payload, + }, + }, + type: SHOW_WEBVIEW_MODAL, +}); + +export const hideWebViewModal = () => ({ + type: HIDE_WEBVIEW_MODAL, +}); + export const setDeviceOrientation = (payload: string) => ({ payload, type: SET_DEVICE_ORIENTATION, diff --git a/src/redux/constants/constants.js b/src/redux/constants/constants.js index 3433645a45..ebf572151a 100644 --- a/src/redux/constants/constants.js +++ b/src/redux/constants/constants.js @@ -59,6 +59,8 @@ export const HIDE_POSTS_THUMBNAILS = 'HIDE_POSTS_THUMBNAILS'; export const RC_OFFER = 'RC_OFFER'; export const TOGGLE_ACCOUNTS_BOTTOM_SHEET = 'TOGGLE_ACCOUNTS_BOTTOM_SHEET'; export const TOGGLE_QR_MODAL = 'TOGGLE_QR_MODAL'; +export const SHOW_WEBVIEW_MODAL = 'SHOW_WEBVIEW_MODAL'; +export const HIDE_WEBVIEW_MODAL = 'HIDE_WEBVIEW_MODAL'; export const SHOW_ACTION_MODAL = 'SHOW_ACTION_MODAL'; export const HIDE_ACTION_MODAL = 'HIDE_ACTION_MODAL'; export const SET_AVATAR_CACHE_STAMP = 'SET_AVATAR_CACHE_STAMP'; diff --git a/src/redux/reducers/uiReducer.ts b/src/redux/reducers/uiReducer.ts index f005cf1265..d355d5405d 100644 --- a/src/redux/reducers/uiReducer.ts +++ b/src/redux/reducers/uiReducer.ts @@ -15,6 +15,8 @@ import { HIDE_REPLY_MODAL, LOGOUT, LOGOUT_DONE, + SHOW_WEBVIEW_MODAL, + HIDE_WEBVIEW_MODAL, } from '../constants/constants'; import { orientations } from '../constants/orientationsConstants'; @@ -34,6 +36,8 @@ interface UiState { avatarCacheStamp: number; profileModalUsername: string; isVisibleQRModal: boolean; + webViewModalData: any; + isVisibleWebViewModal: boolean; deviceOrientation: string; lockedOrientation: string; replyModalVisible: boolean; @@ -51,6 +55,8 @@ const initialState: UiState = { avatarCacheStamp: 0, profileModalUsername: '', isVisibleQRModal: false, + isVisibleWebViewModal: false, + webViewModalData: null, deviceOrientation: orientations.PORTRAIT, lockedOrientation: orientations.PORTRAIT, replyModalData: null, @@ -123,6 +129,18 @@ export default function (state = initialState, action): UiState { ...state, isVisibleQRModal: action.payload, }; + case SHOW_WEBVIEW_MODAL: + return { + ...state, + isVisibleWebViewModal: action.payload.isVisibleWebViewModal, + webViewModalData: action.payload.webViewModalData, + }; + case HIDE_WEBVIEW_MODAL: + return { + ...state, + isVisibleWebViewModal: false, + webViewModalData: null, + }; case SET_DEVICE_ORIENTATION: return { ...state, diff --git a/src/screens/application/children/applicationScreen.tsx b/src/screens/application/children/applicationScreen.tsx index 6a99580172..ed8f5fbbca 100644 --- a/src/screens/application/children/applicationScreen.tsx +++ b/src/screens/application/children/applicationScreen.tsx @@ -26,6 +26,7 @@ import { QuickProfileModal, QRModal, QuickReplyModal, + WebViewModal, } from '../../../components'; // Themes (Styles) @@ -129,6 +130,7 @@ const ApplicationScreen = ({ foregroundNotificationData }) => { + {isShowToastNotification && ( { + let trimUri = uri.trim(); + return trimUri.startsWith('hive://'); +};