From 57f2a066c49d5bd70e78760e1b753def0f188401 Mon Sep 17 00:00:00 2001 From: Narin Ratana Date: Mon, 26 Feb 2024 10:39:57 -0800 Subject: [PATCH 1/5] Remove native calendar code. Pass analyitics onPress events to useExternalLink --- .../src/components/Link/Link.stories.tsx | 56 ++++--- .../components/src/components/Link/Link.tsx | 51 +++---- packages/components/src/utils/OSfunctions.tsx | 139 +++--------------- 3 files changed, 67 insertions(+), 179 deletions(-) diff --git a/packages/components/src/components/Link/Link.stories.tsx b/packages/components/src/components/Link/Link.stories.tsx index 7d2a2925..5a88dde0 100644 --- a/packages/components/src/components/Link/Link.stories.tsx +++ b/packages/components/src/components/Link/Link.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react' -import { Platform, View } from 'react-native' +import { View } from 'react-native' import React from 'react' import { Link, LinkProps } from './Link' @@ -30,8 +30,6 @@ export default meta type Story = StoryObj -const startTime = new Date() -const endTime = new Date(startTime.setMinutes(startTime.getMinutes() + 30)) const location = { lat: 33.7764681, long: -118.1189664, @@ -43,21 +41,6 @@ const location = { zipCode: '90822', }, } -const getLocation = (): string => { - const { lat, long, name, address } = location - if (Platform.OS === 'ios' && lat && long) { - return name || '' - } else if ( - address?.street && - address?.city && - address?.state && - address?.zipCode - ) { - return `${address.street} ${address.city}, ${address.state} ${address.zipCode}` - } else { - return name || '' - } -} export const Calendar: Story = { name: 'Calendar', @@ -65,14 +48,6 @@ export const Calendar: Story = { text: 'Add to calendar', onPress: undefined, // Storybook sends a truthy function shell otherwise type: 'calendar', - calendarData: { - title: 'Test', - startTime: startTime.getTime(), - endTime: endTime.getTime(), - location: getLocation(), - latitude: location.lat, - longitude: location.long, - }, }, } @@ -84,6 +59,9 @@ export const Custom: Story = { onPress: () => { null }, + analytics: { + onPress: () => console.log('pressed'), + }, }, } @@ -124,13 +102,24 @@ export const Directions: Story = { const paragraphText: LinkProps['paragraphText'] = [ // @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText - {text: 'A sentence may include a '}, - {text: 'link that opens in a webview', type: 'custom', onPress: () => {null}, a11yLabel: 'a11y override' }, + { text: 'A sentence may include a ' }, + { + text: 'link that opens in a webview', + type: 'custom', + onPress: () => { + null + }, + a11yLabel: 'a11y override', + }, // @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText - {text: ' or a '}, - {text: 'link that opens in an external app', type: 'url', url: 'https://department-of-veterans-affairs.github.io/va-mobile-app/design/intro'}, + { text: ' or a ' }, + { + text: 'link that opens in an external app', + type: 'url', + url: 'https://department-of-veterans-affairs.github.io/va-mobile-app/design/intro', + }, // @ts-ignore: TS being wrong and thinking all should be LinkProps and none normalText - {text: '.'} + { text: '.' }, ] export const Inline: Story = { @@ -176,5 +165,10 @@ export const URL: Story = { onPress: undefined, // Storybook sends a truthy function shell otherwise type: 'url', url: 'https://www.va.gov/', + analytics: { + onCancel: () => console.log('Analytics event: Canceled'), + onPress: () => console.log('Analytics event: Pressed'), + onConfirm: () => console.log('Analytics event: Confirmed'), + }, }, } diff --git a/packages/components/src/components/Link/Link.tsx b/packages/components/src/components/Link/Link.tsx index 9970aa1f..4c610cb2 100644 --- a/packages/components/src/components/Link/Link.tsx +++ b/packages/components/src/components/Link/Link.tsx @@ -11,10 +11,8 @@ import { import React, { FC, useState } from 'react' import { - CalendarData, FormDirectionsUrl, LocationData, - OnPressCalendar, isIOS, leaveAppPromptText, useExternalLink, @@ -37,7 +35,8 @@ type nullTypeSpecifics = { type calendar = Omit & { type: 'calendar' - calendarData: CalendarData + /** Required onPress override logic */ + onPress: () => void } type call = Omit & { @@ -91,15 +90,11 @@ type linkTypes = | text | url -// TODO: Ticket 170 created to revisit adding analytics after calendar support added/or deemed infeasible -// type analytics = { -// onPress?: () => void -// onConfirm?: () => void -// hasCalendarPermission?: () => void -// onRequestCalendarPermission?: () => void -// onCalendarPermissionSuccess?: () => void -// onCalendarPermissionFailure?: () => void -// } +export type analytics = { + onPress?: () => void + onConfirm?: () => void + onCancel?: () => void +} export type LinkProps = linkTypes & { /** Display text for the link */ @@ -115,7 +110,7 @@ export type LinkProps = linkTypes & { /** Optional override text for leaving app confirmation prompt */ promptText?: leaveAppPromptText /** Optional analytics event logging */ - // analytics?: analytics + analytics?: analytics /** Internally used by 'inline' type. Not recommended for consumer use, but * available to manually insert a link into a paragraph. True builds link * component with RN Text instead of Pressable for improved wrapping behavior */ @@ -134,11 +129,10 @@ export const Link: FC = ({ a11yLabel, a11yHint, promptText, - // analytics, + analytics, inlineSingle, testID, // Type-specific props - calendarData, locationData, paragraphText, phoneNumber, @@ -150,39 +144,42 @@ export const Link: FC = ({ const isDarkMode = colorScheme === 'dark' const launchExternalLink = useExternalLink() - let _onPress: () => Promise = async () => { + let _onPress: () => void = async () => { null // Empty function to keep TS happy a function exists } + /** Handler for links not using launchExternalLink prompt */ + const customOnPress: () => void = () => { + if (analytics?.onPress) analytics.onPress() + if (onPress) onPress() + } + switch (type) { case 'calendar': icon = icon ? icon : { name: 'Calendar' } - _onPress = async (): Promise => { - await OnPressCalendar(calendarData) - return - } + _onPress = customOnPress break case 'call': icon = icon ? icon : { name: 'Phone' } _onPress = async (): Promise => { - launchExternalLink(`tel:${phoneNumber}`) + launchExternalLink(`tel:${phoneNumber}`, analytics) } break case 'call TTY': icon = icon ? icon : { name: 'TTY' } _onPress = async (): Promise => { - launchExternalLink(`tel:${TTYnumber}`) + launchExternalLink(`tel:${TTYnumber}`, analytics) } break case 'custom': icon = icon ? icon : 'no icon' - onPress = onPress + _onPress = customOnPress break case 'directions': icon = icon ? icon : { name: 'Directions' } const directions = FormDirectionsUrl(locationData) _onPress = async (): Promise => { - launchExternalLink(directions, promptText) + launchExternalLink(directions, analytics, promptText) } break case 'inline': @@ -190,13 +187,13 @@ export const Link: FC = ({ case 'text': icon = icon ? icon : { name: 'Text' } _onPress = async (): Promise => { - launchExternalLink(`sms:${textNumber}`) + launchExternalLink(`sms:${textNumber}`, analytics) } break case 'url': icon = icon ? icon : { name: 'ExternalLink' } _onPress = async (): Promise => { - launchExternalLink(url, promptText) + launchExternalLink(url, analytics, promptText) } break } @@ -237,7 +234,7 @@ export const Link: FC = ({ } const pressableProps: PressableProps = { - onPress: onPress ? onPress : _onPress, + onPress: _onPress, ...a11yProps, style: { flexDirection: 'row', diff --git a/packages/components/src/utils/OSfunctions.tsx b/packages/components/src/utils/OSfunctions.tsx index 21f061c9..a8e49168 100644 --- a/packages/components/src/utils/OSfunctions.tsx +++ b/packages/components/src/utils/OSfunctions.tsx @@ -1,6 +1,7 @@ /** A file for functions that leverage OS functionality */ import { Alert, Linking, Platform } from 'react-native' +import { analytics as analyticsType } from 'components/Link/Link' import { useTranslation } from 'react-i18next' export const isIOS = Platform.OS === 'ios' @@ -64,17 +65,24 @@ export type leaveAppPromptText = { */ export function useExternalLink(): ( url: string, + analytics?: analyticsType, text?: leaveAppPromptText, ) => void { const { t } = useTranslation() - return (url: string, text?: leaveAppPromptText) => { - // TODO: Ticket 170 - // logAnalyticsEvent(Events.vama_link_click({ url, ...eventParams })) + return ( + url: string, + analytics?: analyticsType, + text?: leaveAppPromptText, + ) => { + if (analytics?.onPress) analytics?.onPress() + + const onCancelPress = () => { + if (analytics?.onCancel) analytics?.onCancel() + } const onOKPress = () => { - // TODO: Ticket 170 - // logAnalyticsEvent(Events.vama_link_confirm({ url, ...eventParams })) + if (analytics?.onConfirm) analytics?.onConfirm() return Linking.openURL(url) } @@ -85,7 +93,11 @@ export function useExternalLink(): ( if (url.startsWith('http')) { Alert.alert(title, body, [ - { text: cancel, style: 'cancel' }, + { + text: cancel, + style: 'cancel', + onPress: onCancelPress, + }, { text: confirm, onPress: (): Promise => onOKPress(), @@ -97,118 +109,3 @@ export function useExternalLink(): ( } } } - -// TODO: Ticket 169 created to implement support for adding to calendar (or clean up if not feasible) -// Calendar bridge from iOS and Android -// const RNCal = NativeModules.RNCalendar - -export type CalendarData = { - title: string - startTime: number - endTime: number - location: string - latitude: number - longitude: number -} - -export const OnPressCalendar = async ( - calendarData: CalendarData, -): Promise => { - calendarData - // const { title, endTime, startTime, location, latitude, longitude } = calendarData - - // console.log('test') - // let hasPermission = await checkCalendarPermission() - // if (!hasPermission) { - // hasPermission = await requestCalendarPermission() - // } - - // if (hasPermission) { - // await addToCalendar(title, startTime, endTime, location, latitude, longitude) - // } -} - -// /** -// * This function adds requests the device add a date to the native calendar and display it to the user. -// * @param title - The title or name of the event to add to the calendar. -// * @param beginTime - The number of seconds UTC from 1970 when the event will start. -// * @param endTime - The number of seconds UTC from 1970 when the event will end. -// * @param location - The address or name of place where the event will take place. -// * @param latitude - iOS only: Latitude of place where the event will take place. -// * @param longitude - iOS only: Longitude of place where the event will take place. -// * @returns Returns an empty Promise -// */ -// const addToCalendar = async ( -// title: string, -// beginTime: number, -// endTime: number, -// location: string, -// latitude: number, -// longitude: number, -// ): Promise => { -// if (isIOS) { -// await RNCal.addToCalendar( -// title, -// beginTime, -// endTime, -// location, -// latitude, -// longitude, -// ) -// } else { -// await RNCal.addToCalendar(title, beginTime, endTime, location) -// } -// } - -// /** -// * This function is used to check and see if the app has permission to add to the calendar before sending a request -// * to add an event to the calendar. This should be called every time before addToCalendar. -// * @returns Returns a Promise with a boolan value that indicates whether or not the permission is currently granted. -// */ -// const checkCalendarPermission = async (): Promise => { -// if (isIOS) { -// return await RNCal.hasPermission() -// } else { -// return PermissionsAndroid.check( -// PermissionsAndroid.PERMISSIONS.WRITE_CALENDAR, -// ) -// } -// } - -// /** -// * This function is used to request permission from the user to add or edit events in the calendar. -// * This should only be called if checkCalendarPermissions returns a false value. -// * @returns Returns a Promise with a boolean value that indicates whether or not the permission was granted. -// */ -// const requestCalendarPermission = async (): Promise => { -// if (isIOS) { -// return await RNCal.requestPermission() -// } else { -// // RN has android built in so we just use this instead of Native Modules -// try { -// const granted = await PermissionsAndroid.request( -// PermissionsAndroid.PERMISSIONS.WRITE_CALENDAR, -// { -// title: 'Calendar Permission Needed for this Action', -// message: -// 'VA:Health and Benefits needs calendar permission to add your appointments to your calendar', -// buttonNegative: 'Deny', -// buttonPositive: 'Grant', -// }, -// ) -// if (granted === PermissionsAndroid.RESULTS.GRANTED) { -// return true -// } else { -// // todo: if this is "never_ask_again" we need to prompt to go to settings? -// return false -// } -// } catch (err) { -// // logNonFatalErrorToFirebase( -// // err, -// // 'requestCalendarPermission: RnCalendar Error', -// // ) -// console.warn(err) -// return false -// } -// } -// } From 53bd614b2fc51e17f2e045a92c6b523e21d841a4 Mon Sep 17 00:00:00 2001 From: Narin Ratana Date: Mon, 26 Feb 2024 10:52:08 -0800 Subject: [PATCH 2/5] Update event for custom link story --- packages/components/src/components/Link/Link.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/Link/Link.stories.tsx b/packages/components/src/components/Link/Link.stories.tsx index 5a88dde0..0491a4b3 100644 --- a/packages/components/src/components/Link/Link.stories.tsx +++ b/packages/components/src/components/Link/Link.stories.tsx @@ -60,7 +60,7 @@ export const Custom: Story = { null }, analytics: { - onPress: () => console.log('pressed'), + onPress: () => console.log('Analytics event: Pressed'), }, }, } From 6a7e01de4c6142cb00a8deb3d4750dc6f70cc18b Mon Sep 17 00:00:00 2001 From: Narin Ratana Date: Mon, 26 Feb 2024 12:29:11 -0800 Subject: [PATCH 3/5] Remove unneeded native module --- .../src/utils/nativeModules/RNCalendar.kt | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 packages/components/src/utils/nativeModules/RNCalendar.kt diff --git a/packages/components/src/utils/nativeModules/RNCalendar.kt b/packages/components/src/utils/nativeModules/RNCalendar.kt deleted file mode 100644 index 693d6f46..00000000 --- a/packages/components/src/utils/nativeModules/RNCalendar.kt +++ /dev/null @@ -1,60 +0,0 @@ -package gov.va.mobileapp.native_modules - -import android.Manifest.permission.WRITE_CALENDAR -import android.content.Intent -import android.content.Intent.ACTION_INSERT -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.os.Bundle -import android.provider.CalendarContract.EXTRA_EVENT_BEGIN_TIME -import android.provider.CalendarContract.EXTRA_EVENT_END_TIME -import android.provider.CalendarContract.Events.* -import android.util.Log -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod - -const val INSERT_EVENT_CODE = 1001 - -/** - * React Native NativeModule to expose Calendar functionality to a React Native instance - */ -class RNCalendar(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { - override fun getName() = "RNCalendar" - - /** - * This method is used to check to see if the user has granted the WRITE_CALENDAR permission to the app - * - * @return true if the app has write permissions and false if it has not asked or if the user has - * permanently denied the app. - */ - @ReactMethod - fun checkHasCalendarPermission(): Boolean { - return reactApplicationContext.checkSelfPermission(WRITE_CALENDAR) == PERMISSION_GRANTED - } - - /** - * This method receives a title, start, end, and location for an event to send to the calendar - * - * @param title The title to display for the new event. - * @param beginTime number of seconds UTC since 1970 when the event will start - * @param endTime number of seconds UTC since 1970 when the event will end. - * @param location The address or name of place where the event will be taking place - */ - @ReactMethod - fun addToCalendar(title: String, beginTime: Int, endTime: Int, location: String) { - Log.d("RNCal", "${beginTime * 1000}, $endTime") - val i = Intent(ACTION_INSERT).apply { - data = CONTENT_URI - type = "vnd.android.cursor.item/event" - putExtra(TITLE, title) - putExtra(EVENT_LOCATION, location) - putExtra(EXTRA_EVENT_BEGIN_TIME, beginTime.toLong() * 1000L) - putExtra(EXTRA_EVENT_END_TIME, endTime .toLong() * 1000L) - addFlags(FLAG_ACTIVITY_NEW_TASK) - } - - reactApplicationContext.startActivityForResult(i, INSERT_EVENT_CODE, Bundle.EMPTY) - } - -} \ No newline at end of file From 8669ededcbe6753ba2b3c3b9c9b273d532a6b3af Mon Sep 17 00:00:00 2001 From: Narin Ratana Date: Mon, 26 Feb 2024 12:34:09 -0800 Subject: [PATCH 4/5] Rename analytics type to LinkAnalytics --- packages/components/src/components/Link/Link.tsx | 4 ++-- packages/components/src/utils/OSfunctions.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/src/components/Link/Link.tsx b/packages/components/src/components/Link/Link.tsx index 4c610cb2..de6bde24 100644 --- a/packages/components/src/components/Link/Link.tsx +++ b/packages/components/src/components/Link/Link.tsx @@ -90,7 +90,7 @@ type linkTypes = | text | url -export type analytics = { +export type LinkAnalytics = { onPress?: () => void onConfirm?: () => void onCancel?: () => void @@ -110,7 +110,7 @@ export type LinkProps = linkTypes & { /** Optional override text for leaving app confirmation prompt */ promptText?: leaveAppPromptText /** Optional analytics event logging */ - analytics?: analytics + analytics?: LinkAnalytics /** Internally used by 'inline' type. Not recommended for consumer use, but * available to manually insert a link into a paragraph. True builds link * component with RN Text instead of Pressable for improved wrapping behavior */ diff --git a/packages/components/src/utils/OSfunctions.tsx b/packages/components/src/utils/OSfunctions.tsx index a8e49168..9d13a08d 100644 --- a/packages/components/src/utils/OSfunctions.tsx +++ b/packages/components/src/utils/OSfunctions.tsx @@ -1,7 +1,7 @@ /** A file for functions that leverage OS functionality */ import { Alert, Linking, Platform } from 'react-native' -import { analytics as analyticsType } from 'components/Link/Link' +import { LinkAnalytics } from 'components/Link/Link' import { useTranslation } from 'react-i18next' export const isIOS = Platform.OS === 'ios' @@ -65,14 +65,14 @@ export type leaveAppPromptText = { */ export function useExternalLink(): ( url: string, - analytics?: analyticsType, + analytics?: LinkAnalytics, text?: leaveAppPromptText, ) => void { const { t } = useTranslation() return ( url: string, - analytics?: analyticsType, + analytics?: LinkAnalytics, text?: leaveAppPromptText, ) => { if (analytics?.onPress) analytics?.onPress() From 4d6aae7fb74e6ca39d976f5c82319f04e094372c Mon Sep 17 00:00:00 2001 From: Narin Ratana Date: Mon, 26 Feb 2024 12:35:05 -0800 Subject: [PATCH 5/5] Remove redundant conditional chaining --- packages/components/src/utils/OSfunctions.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/utils/OSfunctions.tsx b/packages/components/src/utils/OSfunctions.tsx index 9d13a08d..d42f04a5 100644 --- a/packages/components/src/utils/OSfunctions.tsx +++ b/packages/components/src/utils/OSfunctions.tsx @@ -75,14 +75,14 @@ export function useExternalLink(): ( analytics?: LinkAnalytics, text?: leaveAppPromptText, ) => { - if (analytics?.onPress) analytics?.onPress() + if (analytics?.onPress) analytics.onPress() const onCancelPress = () => { - if (analytics?.onCancel) analytics?.onCancel() + if (analytics?.onCancel) analytics.onCancel() } const onOKPress = () => { - if (analytics?.onConfirm) analytics?.onConfirm() + if (analytics?.onConfirm) analytics.onConfirm() return Linking.openURL(url) }