diff --git a/packages/mobile/src/alert/AlertBanner.test.tsx b/packages/mobile/src/alert/AlertBanner.test.tsx index 17b3cc790d9..6d7f79ad9d4 100644 --- a/packages/mobile/src/alert/AlertBanner.test.tsx +++ b/packages/mobile/src/alert/AlertBanner.test.tsx @@ -1,26 +1,26 @@ import * as React from 'react' -import 'react-native' -import { render } from 'react-native-testing-library' -import { AlertBanner } from 'src/alert/AlertBanner' +import { fireEvent, render } from 'react-native-testing-library' +import { Provider } from 'react-redux' +import AlertBanner from 'src/alert/AlertBanner' import { ErrorDisplayType } from 'src/alert/reducer' +import { createMockStore } from 'test/utils' describe('AlertBanner', () => { - const baseProps = { - hideAlert: jest.fn(), - } - describe('when message passed in', () => { it('renders message', () => { const { toJSON } = render( - + + + ) expect(toJSON()).toMatchSnapshot() }) @@ -29,16 +29,19 @@ describe('AlertBanner', () => { describe('when message and title passed in', () => { it('renders title with message', () => { const { toJSON } = render( - + + + ) expect(toJSON()).toMatchSnapshot() }) @@ -47,17 +50,43 @@ describe('AlertBanner', () => { describe('when error message passed in', () => { it('renders error message', () => { const { toJSON } = render( - + + + ) expect(toJSON()).toMatchSnapshot() }) }) + + describe('when an action is provided', () => { + it('it dispatches the action when pressed', () => { + const store = createMockStore({ + alert: { + type: 'message', + displayMethod: ErrorDisplayType.BANNER, + message: 'My message', + dismissAfter: 0, + action: { type: 'MY_ACTION' }, + }, + }) + const { toJSON, getByTestId } = render( + + + + ) + expect(toJSON()).toMatchSnapshot() + + fireEvent.press(getByTestId('SmartTopAlertTouchable')) + expect(store.getActions()).toEqual([{ type: 'MY_ACTION' }]) + }) + }) }) diff --git a/packages/mobile/src/alert/AlertBanner.tsx b/packages/mobile/src/alert/AlertBanner.tsx index 87efc0deecb..76bf5e0a1e6 100644 --- a/packages/mobile/src/alert/AlertBanner.tsx +++ b/packages/mobile/src/alert/AlertBanner.tsx @@ -1,50 +1,30 @@ import SmartTopAlert, { AlertTypes } from '@celo/react-components/components/SmartTopAlert' import * as React from 'react' -import { connect } from 'react-redux' +import { useDispatch } from 'react-redux' import { hideAlert } from 'src/alert/actions' -import { ErrorDisplayType, State as AlertState } from 'src/alert/reducer' -import { RootState } from 'src/redux/reducers' +import { ErrorDisplayType } from 'src/alert/reducer' +import useSelector from 'src/redux/useSelector' -interface StateProps { - alert: AlertState | null -} - -interface DispatchProps { - hideAlert: typeof hideAlert -} +export default function AlertBanner() { + const alert = useSelector((state) => state.alert) + const dispatch = useDispatch() -type Props = StateProps & DispatchProps - -const mapStateToProps = (state: RootState): StateProps => { - return { - alert: state.alert, + const onPress = () => { + const action = alert?.action ?? hideAlert() + dispatch(action) } -} -const mapDispatchToProps = { - hideAlert, + return ( + + ) } - -export class AlertBanner extends React.Component { - render() { - const { alert, hideAlert: hideAlertAction } = this.props - - return ( - - ) - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps -)(AlertBanner) diff --git a/packages/mobile/src/alert/__snapshots__/AlertBanner.test.tsx.snap b/packages/mobile/src/alert/__snapshots__/AlertBanner.test.tsx.snap index bdf16d2c638..07ab5fd4c9b 100644 --- a/packages/mobile/src/alert/__snapshots__/AlertBanner.test.tsx.snap +++ b/packages/mobile/src/alert/__snapshots__/AlertBanner.test.tsx.snap @@ -1,5 +1,67 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`AlertBanner when an action is provided it dispatches the action when pressed 1`] = ` + + + + My message + + + +`; + exports[`AlertBanner when error message passed in renders error message 1`] = ` @@ -135,6 +198,7 @@ exports[`AlertBanner when message and title passed in renders title with message ], } } + testID="SmartTopAlertTouchable" > @@ -217,6 +281,7 @@ exports[`AlertBanner when message passed in renders message 1`] = ` ], } } + testID="SmartTopAlertTouchable" > diff --git a/packages/mobile/src/alert/actions.ts b/packages/mobile/src/alert/actions.ts index 24aa6a21b36..97d5a8e8644 100644 --- a/packages/mobile/src/alert/actions.ts +++ b/packages/mobile/src/alert/actions.ts @@ -2,6 +2,7 @@ import { TOptions } from 'i18next' import { ErrorDisplayType } from 'src/alert/reducer' import { AppEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { OpenUrlAction } from 'src/app/actions' import { ErrorMessages } from 'src/app/ErrorMessages' import { ALERT_BANNER_DURATION } from 'src/config' import i18n, { Namespaces } from 'src/i18n' @@ -16,6 +17,11 @@ enum AlertTypes { ERROR = 'error', } +// Possible actions to dispatch when tapping the alert (or its button) +// Could be any redux action, but limiting for now +// As we don't yet have a type encompassing all redux actions +type AlertAction = OpenUrlAction + interface ShowAlertAction { type: Actions.SHOW alertType: AlertTypes @@ -23,6 +29,7 @@ interface ShowAlertAction { message: string dismissAfter?: number | null buttonMessage?: string | null + action?: AlertAction | null title?: string | null underlyingError?: ErrorMessages | null } @@ -31,9 +38,10 @@ export const showMessage = ( message: string, dismissAfter?: number | null, buttonMessage?: string | null, + action?: AlertAction | null, title?: string | null ): ShowAlertAction => { - return showAlert(AlertTypes.MESSAGE, message, dismissAfter, buttonMessage, title) + return showAlert(AlertTypes.MESSAGE, message, dismissAfter, buttonMessage, action, title) } export const showError = ( @@ -48,6 +56,7 @@ export const showError = ( dismissAfter, null, null, + null, error ) } @@ -79,6 +88,7 @@ const showAlert = ( message: string, dismissAfter: number | null = ALERT_BANNER_DURATION, buttonMessage?: string | null, + action?: AlertAction | null, title?: string | null, underlyingError?: ErrorMessages | null ): ShowAlertAction => { @@ -89,6 +99,7 @@ const showAlert = ( message, dismissAfter, buttonMessage, + action, title, underlyingError, } diff --git a/packages/mobile/src/alert/reducer.ts b/packages/mobile/src/alert/reducer.ts index cc13acaa47d..15f28ef20ab 100644 --- a/packages/mobile/src/alert/reducer.ts +++ b/packages/mobile/src/alert/reducer.ts @@ -7,19 +7,20 @@ export enum ErrorDisplayType { 'INLINE', } -export interface State { +export type State = { type: 'message' | 'error' displayMethod: ErrorDisplayType message: string dismissAfter?: number | null buttonMessage?: string | null + action?: object | null title?: string | null underlyingError?: ErrorMessages | null -} +} | null const initialState = null -export const reducer = (state: State | null = initialState, action: ActionTypes): State | null => { +export const reducer = (state: State = initialState, action: ActionTypes): State => { switch (action.type) { case Actions.SHOW: return { @@ -28,12 +29,17 @@ export const reducer = (state: State | null = initialState, action: ActionTypes) message: action.message, dismissAfter: action.dismissAfter, buttonMessage: action.buttonMessage, + action: action.action, title: action.title, underlyingError: action.underlyingError, } case Actions.HIDE: return null default: + if (state?.action === action) { + // Hide alert when the alert action is dispatched + return null + } return state } } diff --git a/packages/mobile/src/app/actions.ts b/packages/mobile/src/app/actions.ts index a36baf99248..3fcdbac72cb 100644 --- a/packages/mobile/src/app/actions.ts +++ b/packages/mobile/src/app/actions.ts @@ -25,6 +25,7 @@ export enum Actions { LOCK = 'APP/LOCK', UNLOCK = 'APP/UNLOCK', SET_SESSION_ID = 'SET_SESSION_ID', + OPEN_URL = 'APP/OPEN_URL', } export interface SetAppState { @@ -87,6 +88,11 @@ export interface SetSessionId { sessionId: string } +export interface OpenUrlAction { + type: Actions.OPEN_URL + url: string +} + export type ActionTypes = | SetAppState | SetLoggedIn @@ -101,6 +107,7 @@ export type ActionTypes = | Lock | Unlock | SetSessionId + | OpenUrlAction export const setAppState = (state: string) => ({ type: Actions.SET_APP_STATE, @@ -169,3 +176,8 @@ export const setSessionId = (sessionId: string) => ({ type: Actions.SET_SESSION_ID, sessionId, }) + +export const openUrl = (url: string): OpenUrlAction => ({ + type: Actions.OPEN_URL, + url, +}) diff --git a/packages/mobile/src/app/saga.ts b/packages/mobile/src/app/saga.ts index 4ac1735bedc..acdcf68be3b 100644 --- a/packages/mobile/src/app/saga.ts +++ b/packages/mobile/src/app/saga.ts @@ -1,6 +1,15 @@ import { AppState, Linking } from 'react-native' import { eventChannel } from 'redux-saga' -import { call, cancelled, put, select, spawn, take, takeLatest } from 'redux-saga/effects' +import { + call, + cancelled, + put, + select, + spawn, + take, + takeEvery, + takeLatest, +} from 'redux-saga/effects' import { AppEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import { @@ -8,6 +17,7 @@ import { appLock, OpenDeepLink, openDeepLink, + OpenUrlAction, SetAppState, setAppState, setLanguage, @@ -21,6 +31,7 @@ import { CodeInputType } from 'src/identity/verification' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { handlePaymentDeeplink } from 'src/send/utils' +import { navigateToURI } from 'src/utils/linking' import Logger from 'src/utils/Logger' import { clockInSync } from 'src/utils/time' import { parse } from 'url' @@ -87,6 +98,16 @@ export function* watchDeepLinks() { yield takeLatest(Actions.OPEN_DEEP_LINK, handleDeepLink) } +export function* handleOpenUrl(action: OpenUrlAction) { + const { url } = action + Logger.debug(TAG, 'Handling url', url) + yield call(navigateToURI, url) +} + +export function* watchOpenUrl() { + yield takeEvery(Actions.OPEN_URL, handleOpenUrl) +} + function createAppStateChannel() { return eventChannel((emit: any) => { AppState.addEventListener('change', emit) @@ -130,6 +151,7 @@ export function* handleSetAppState(action: SetAppState) { export function* appSaga() { yield spawn(watchDeepLinks) + yield spawn(watchOpenUrl) yield spawn(watchAppState) yield takeLatest(Actions.SET_APP_STATE, handleSetAppState) } diff --git a/packages/mobile/src/firebase/firebase.ts b/packages/mobile/src/firebase/firebase.ts index c53cffe3469..b1a55e6ec4c 100644 --- a/packages/mobile/src/firebase/firebase.ts +++ b/packages/mobile/src/firebase/firebase.ts @@ -138,14 +138,13 @@ export function* initializeCloudMessaging(app: ReactNativeFirebase.Module, addre }) yield spawn(watchFirebaseNotificationChannel, channelOnNotification) - const initialNotification = yield call([app.messaging(), 'getInitialNotification']) + // Manual type checking because yield calls can't infer return type yet :'( + const initialNotification: Awaited> = yield call([app.messaging(), 'getInitialNotification']) if (initialNotification) { - Logger.info(TAG, 'App opened fresh via a notification') - yield call( - handleNotification, - initialNotification.notification, - NotificationReceiveState.APP_OPENED_FRESH - ) + Logger.info(TAG, 'App opened fresh via a notification', JSON.stringify(initialNotification)) + yield call(handleNotification, initialNotification, NotificationReceiveState.APP_OPENED_FRESH) } app.messaging().setBackgroundMessageHandler((remoteMessage) => { diff --git a/packages/mobile/src/firebase/notifications.test.ts b/packages/mobile/src/firebase/notifications.test.ts new file mode 100644 index 00000000000..99fbbd6677f --- /dev/null +++ b/packages/mobile/src/firebase/notifications.test.ts @@ -0,0 +1,144 @@ +import BigNumber from 'bignumber.js' +import { expectSaga } from 'redux-saga-test-plan' +import { select } from 'redux-saga/effects' +import { showMessage } from 'src/alert/actions' +import { openUrl } from 'src/app/actions' +import { handleNotification } from 'src/firebase/notifications' +import { addressToE164NumberSelector } from 'src/identity/reducer' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { NotificationReceiveState, NotificationTypes } from 'src/notifications/types' +import { recipientCacheSelector } from 'src/recipients/reducer' + +describe(handleNotification, () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('with a simple notification', () => { + const message = { + notification: { title: 'My title', body: 'My Body' }, + } + + it('shows the in-app message when the app is already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_ALREADY_OPEN) + .put(showMessage('My Body', undefined, null, null, 'My title')) + .run() + }) + + it('has no effect if the app is not already in the foreground', async () => { + const result = await expectSaga( + handleNotification, + message, + NotificationReceiveState.APP_OPENED_FRESH + ).run() + + expect(result.toJSON()).toEqual({}) + }) + }) + + describe("with a notification with an 'open url' semantic", () => { + const message = { + notification: { title: 'My title', body: 'My Body' }, + data: { ou: 'https://celo.org' }, + } + + it('shows the in-app message when the app is already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_ALREADY_OPEN) + .put(showMessage('My Body', undefined, null, openUrl('https://celo.org'), 'My title')) + .run() + }) + + it('directly opens the url if the app is not already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_OPENED_FRESH) + .put(openUrl('https://celo.org')) + .run() + }) + }) + + describe('with a payment received notification', () => { + const message = { + notification: { title: 'My title', body: 'My Body' }, + data: { + type: NotificationTypes.PAYMENT_RECEIVED, + sender: '0xTEST', + value: '10', + currency: 'dollar', + timestamp: 1, + }, + } + + it('shows the in-app message when the app is already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_ALREADY_OPEN) + .put(showMessage('My Body', undefined, null, null, 'My title')) + .run() + + expect(navigate).not.toHaveBeenCalled() + }) + + it('navigates to the transaction review screen if the app is not already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_OPENED_FRESH) + .provide([ + [select(addressToE164NumberSelector), {}], + [select(recipientCacheSelector), {}], + ]) + .run() + + expect(navigate).toHaveBeenCalledWith(Screens.TransactionReview, { + confirmationProps: { + address: '0xtest', + amount: { currencyCode: 'cUSD', value: new BigNumber('1e-17') }, + comment: undefined, + recipient: undefined, + type: 'RECEIVED', + }, + reviewProps: { + header: 'walletFlow5:transactionHeaderReceived', + timestamp: 1, + type: 'RECEIVED', + }, + }) + }) + }) + + describe('with a payment request notification', () => { + const message = { + notification: { title: 'My title', body: 'My Body' }, + data: { + type: NotificationTypes.PAYMENT_REQUESTED, + uid: 'abc', + requesterAddress: '0xTEST', + amount: '10', + currency: 'dollar', + comment: 'Pizza', + }, + } + + it('shows the in-app message when the app is already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_ALREADY_OPEN) + .put(showMessage('My Body', undefined, null, null, 'My title')) + .run() + + expect(navigate).not.toHaveBeenCalled() + }) + + it('navigates to the send confirmation screen if the app is not already in the foreground', async () => { + await expectSaga(handleNotification, message, NotificationReceiveState.APP_OPENED_FRESH) + .provide([ + [select(addressToE164NumberSelector), {}], + [select(recipientCacheSelector), {}], + ]) + .run() + + expect(navigate).toHaveBeenCalledWith(Screens.SendConfirmation, { + transactionData: { + amount: new BigNumber('10'), + firebasePendingRequestUid: 'abc', + reason: 'Pizza', + recipient: { address: '0xTEST', displayName: '0xTEST', kind: 'Address' }, + type: 'PAY_REQUEST', + }, + }) + }) + }) +}) diff --git a/packages/mobile/src/firebase/notifications.ts b/packages/mobile/src/firebase/notifications.ts index 6b33db4cc5a..a347261e098 100644 --- a/packages/mobile/src/firebase/notifications.ts +++ b/packages/mobile/src/firebase/notifications.ts @@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js' import { call, put, select } from 'redux-saga/effects' import { showMessage } from 'src/alert/actions' import { TokenTransactionType } from 'src/apollo/types' +import { openUrl } from 'src/app/actions' import { CURRENCIES, resolveCurrency } from 'src/geth/consts' import { addressToE164NumberSelector } from 'src/identity/reducer' import { @@ -84,12 +85,31 @@ export function* handleNotification( message: FirebaseMessagingTypes.RemoteMessage, notificationState: NotificationReceiveState ) { + // See if this is a notification with an open url action (`ou` prop in the data) + const urlToOpen = message.data?.ou + if (notificationState === NotificationReceiveState.APP_ALREADY_OPEN) { - const title = message.notification?.title + const { title, body } = message.notification ?? {} if (title) { - yield put(showMessage(title)) + yield put( + showMessage( + body || title, + undefined, + null, + urlToOpen ? openUrl(urlToOpen) : null, + body ? title : null + ) + ) + } + } else { + // Notification was received while app wasn't already open (i.e. tapped to act on it) + // So directly handle the action if any + if (urlToOpen) { + yield put(openUrl(urlToOpen)) + return } } + switch (message.data?.type) { case NotificationTypes.PAYMENT_REQUESTED: yield call( diff --git a/packages/mobile/src/home/WalletHome.test.tsx b/packages/mobile/src/home/WalletHome.test.tsx index 9f1c593594a..ed2998dd32c 100644 --- a/packages/mobile/src/home/WalletHome.test.tsx +++ b/packages/mobile/src/home/WalletHome.test.tsx @@ -50,7 +50,13 @@ describe('Testnet banner', () => { ) expect(tree).toMatchSnapshot() - expect(showMessageMock).toHaveBeenCalledWith('testnetAlert.1', 5000, null, 'testnetAlert.0') + expect(showMessageMock).toHaveBeenCalledWith( + 'testnetAlert.1', + 5000, + null, + null, + 'testnetAlert.0' + ) }) it('Renders when disconnected', async () => { const store = createMockStoreAppDisconnected() diff --git a/packages/mobile/src/home/WalletHome.tsx b/packages/mobile/src/home/WalletHome.tsx index aed6b1e13dc..5d7a635d7e6 100644 --- a/packages/mobile/src/home/WalletHome.tsx +++ b/packages/mobile/src/home/WalletHome.tsx @@ -145,6 +145,7 @@ export class WalletHome extends React.Component { t('testnetAlert.1', { testnet: _.startCase(DEFAULT_TESTNET) }), ALERT_BANNER_DURATION, null, + null, t('testnetAlert.0', { testnet: _.startCase(DEFAULT_TESTNET) }) ) } diff --git a/packages/mobile/src/send/SendAmount.test.tsx b/packages/mobile/src/send/SendAmount.test.tsx index 2101276092b..a2daa0647e8 100644 --- a/packages/mobile/src/send/SendAmount.test.tsx +++ b/packages/mobile/src/send/SendAmount.test.tsx @@ -120,6 +120,7 @@ describe('SendAmount', () => { fireEvent.press(reviewButton) expect(store.getActions()).toEqual([ { + action: null, alertType: 'error', buttonMessage: null, dismissAfter: 5000, @@ -148,6 +149,7 @@ describe('SendAmount', () => { fireEvent.press(sendButton) expect(store.getActions()).toEqual([ { + action: null, alertType: 'error', buttonMessage: null, dismissAfter: 5000, diff --git a/packages/mobile/src/shared/__snapshots__/BackupPrompt.test.tsx.snap b/packages/mobile/src/shared/__snapshots__/BackupPrompt.test.tsx.snap index 15c3c526fe7..fd75bada040 100644 --- a/packages/mobile/src/shared/__snapshots__/BackupPrompt.test.tsx.snap +++ b/packages/mobile/src/shared/__snapshots__/BackupPrompt.test.tsx.snap @@ -36,6 +36,7 @@ exports[`BackupPrompt renders correctly 1`] = ` ], } } + testID="SmartTopAlertTouchable" > diff --git a/packages/react-components/components/SmartTopAlert.tsx b/packages/react-components/components/SmartTopAlert.tsx index a4b9e42c003..27a55b3d241 100644 --- a/packages/react-components/components/SmartTopAlert.tsx +++ b/packages/react-components/components/SmartTopAlert.tsx @@ -38,7 +38,7 @@ function SmartTopAlert(props: Props) { const alertState = useMemo(() => { // tslint bug? // tslint:disable-next-line: no-shadowed-variable - const { type, title, text, buttonMessage, dismissAfter, onPress, isVisible } = props + const { isVisible, type, title, text, buttonMessage, dismissAfter, onPress } = props if (isVisible) { return { type, @@ -53,6 +53,7 @@ function SmartTopAlert(props: Props) { } }, [ props.timestamp, + props.isVisible, props.type, props.title, props.text, @@ -138,7 +139,7 @@ function SmartTopAlert(props: Props) { return ( - + {isError && } - - {!!title && {title} } + + {!!title && {title} } {text} {buttonMessage && ( diff --git a/packages/react-components/components/__snapshots__/SmartTopAlert.test.tsx.snap b/packages/react-components/components/__snapshots__/SmartTopAlert.test.tsx.snap index bcc9be62ddf..b555e7f8295 100644 --- a/packages/react-components/components/__snapshots__/SmartTopAlert.test.tsx.snap +++ b/packages/react-components/components/__snapshots__/SmartTopAlert.test.tsx.snap @@ -36,6 +36,7 @@ exports[`SmartTopAlert renders correctly 1`] = ` ], } } + testID="SmartTopAlertTouchable" >