diff --git a/app/App.tsx b/app/App.tsx index 832f1bbae..9b5e4364a 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -9,6 +9,7 @@ import ErrorBoundary from './src/components/ErrorBoundary'; import AppNavigation from './src/navigation'; import { AuthProvider } from './src/providers/authProvider'; import { DatabaseProvider } from './src/providers/databaseProvider'; +import { FeedbackProvider } from './src/providers/feedbackProvider'; import { LoggerProvider } from './src/providers/loggerProvider'; import { NotificationTrackingProvider } from './src/providers/notificationTrackingProvider'; import { PassportProvider } from './src/providers/passportDataProvider'; @@ -31,7 +32,9 @@ function App(): React.JSX.Element { - + + + diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 03bcc7324..a79935bc4 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -25,8 +25,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1148.0) - aws-sdk-core (3.229.0) + aws-partitions (1.1150.0) + aws-sdk-core (3.230.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -99,7 +99,7 @@ GEM drb (2.2.3) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.16.0) + ethon (0.17.0) ffi (>= 1.15.0) excon (0.112.0) faraday (1.10.4) diff --git a/app/src/Sentry.ts b/app/src/Sentry.ts index 46521b7fd..9986f827e 100644 --- a/app/src/Sentry.ts +++ b/app/src/Sentry.ts @@ -15,6 +15,35 @@ export const captureException = ( }); }; +export const captureFeedback = ( + feedback: string, + context?: Record, +) => { + if (isSentryDisabled) { + return; + } + + Sentry.captureFeedback( + { + message: feedback, + name: context?.name, + email: context?.email, + tags: { + category: context?.category || 'general', + source: context?.source || 'feedback_modal', + }, + }, + { + captureContext: { + tags: { + category: context?.category || 'general', + source: context?.source || 'feedback_modal', + }, + }, + }, + ); +}; + export const captureMessage = ( message: string, context?: Record, @@ -54,6 +83,22 @@ export const initSentry = () => { Sentry.consoleLoggingIntegration({ levels: ['log', 'error', 'warn', 'info', 'debug'], }), + Sentry.feedbackIntegration({ + buttonOptions: { + styles: { + triggerButton: { + position: 'absolute', + top: 20, + right: 20, + bottom: undefined, + marginTop: 100, + }, + }, + }, + enableTakeScreenshot: true, + namePlaceholder: 'Fullname', + emailPlaceholder: 'Email', + }), ], _experiments: { enableLogs: true, diff --git a/app/src/Sentry.web.ts b/app/src/Sentry.web.ts index b82082f7f..e100b7b45 100644 --- a/app/src/Sentry.web.ts +++ b/app/src/Sentry.web.ts @@ -15,6 +15,35 @@ export const captureException = ( }); }; +export const captureFeedback = ( + feedback: string, + context?: Record, +) => { + if (isSentryDisabled) { + return; + } + + Sentry.captureFeedback( + { + message: feedback, + name: context?.name, + email: context?.email, + tags: { + category: context?.category || 'general', + source: context?.source || 'feedback_modal', + }, + }, + { + captureContext: { + tags: { + category: context?.category || 'general', + source: context?.source || 'feedback_modal', + }, + }, + }, + ); +}; + export const captureMessage = ( message: string, context?: Record, @@ -49,6 +78,24 @@ export const initSentry = () => { } return event; }, + integrations: [ + Sentry.feedbackIntegration({ + buttonOptions: { + styles: { + triggerButton: { + position: 'absolute', + top: 20, + right: 20, + bottom: undefined, + marginTop: 100, + }, + }, + }, + enableTakeScreenshot: true, + namePlaceholder: 'Fullname', + emailPlaceholder: 'Email', + }), + ], }); return Sentry; }; diff --git a/app/src/components/FeedbackModal.tsx b/app/src/components/FeedbackModal.tsx new file mode 100644 index 000000000..cd7a789eb --- /dev/null +++ b/app/src/components/FeedbackModal.tsx @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import React, { useState } from 'react'; +import { Alert, Modal, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Button, XStack, YStack } from 'tamagui'; + +import { Caption } from '@/components/typography/Caption'; +import { black, slate400, white, zinc800, zinc900 } from '@/utils/colors'; +import { advercase, dinot } from '@/utils/fonts'; + +interface FeedbackModalProps { + visible: boolean; + onClose: () => void; + onSubmit: ( + feedback: string, + category: string, + name?: string, + email?: string, + ) => void; +} + +const FeedbackModal: React.FC = ({ + visible, + onClose, + onSubmit, +}) => { + const [feedback, setFeedback] = useState(''); + const [category, setCategory] = useState('general'); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const categories = [ + { value: 'general', label: 'General Feedback' }, + { value: 'bug', label: 'Bug Report' }, + { value: 'feature', label: 'Feature Request' }, + { value: 'ui', label: 'UI/UX Issue' }, + ]; + + const handleSubmit = async () => { + if (!feedback.trim()) { + Alert.alert('Error', 'Please enter your feedback'); + return; + } + + setIsSubmitting(true); + try { + await onSubmit( + feedback.trim(), + category, + name.trim() || undefined, + email.trim() || undefined, + ); + setFeedback(''); + setCategory('general'); + setName(''); + setEmail(''); + onClose(); + Alert.alert('Success', 'Thank you for your feedback!'); + } catch (error) { + console.error('Error submitting feedback:', error); + Alert.alert('Error', 'Failed to submit feedback. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (feedback.trim() || name.trim() || email.trim()) { + Alert.alert( + 'Discard Feedback?', + 'You have unsaved feedback. Are you sure you want to close?', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Discard', style: 'destructive', onPress: onClose }, + ], + ); + } else { + onClose(); + } + }; + + return ( + + + + + + Send Feedback + + + + + Category + + {categories.map(cat => ( + + ))} + + + + + + Contact Information (Optional) + + + + + + + + + Your Feedback + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modalContainer: { + backgroundColor: zinc900, + borderRadius: 16, + width: '100%', + maxWidth: 400, + maxHeight: '80%', + borderWidth: 1, + borderColor: zinc800, + }, + title: { + fontFamily: advercase, + fontSize: 24, + fontWeight: '600', + color: white, + }, + label: { + fontFamily: dinot, + color: white, + fontSize: 14, + fontWeight: '500', + }, + textInput: { + backgroundColor: black, + borderWidth: 1, + borderColor: zinc800, + borderRadius: 8, + padding: 12, + color: white, + fontSize: 16, + fontFamily: dinot, + minHeight: 120, + }, +}); + +export default FeedbackModal; diff --git a/app/src/components/FeedbackModalScreen.tsx b/app/src/components/FeedbackModalScreen.tsx new file mode 100644 index 000000000..d8721a7b2 --- /dev/null +++ b/app/src/components/FeedbackModalScreen.tsx @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import React, { useCallback } from 'react'; +import { Modal, StyleSheet } from 'react-native'; +import { styled, View, XStack, YStack } from 'tamagui'; + +import { PrimaryButton } from '@/components/buttons/PrimaryButton'; +import { SecondaryButton } from '@/components/buttons/SecondaryButton'; +import Description from '@/components/typography/Description'; +import { Title } from '@/components/typography/Title'; +import ModalClose from '@/images/icons/modal_close.svg'; +import LogoInversed from '@/images/logo_inversed.svg'; +import { white } from '@/utils/colors'; +import { confirmTap, impactLight } from '@/utils/haptic'; + +const ModalBackDrop = styled(View, { + display: 'flex', + alignItems: 'center', + // TODO cannot use filter(blur), so increased opacity + backgroundColor: '#000000BB', + alignContent: 'center', + alignSelf: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', +}); + +export interface FeedbackModalScreenParams { + titleText: string; + bodyText: string; + buttonText: string; + secondaryButtonText?: string; + onButtonPress: (() => Promise) | (() => void); + onSecondaryButtonPress?: (() => Promise) | (() => void); + onModalDismiss?: () => void; + preventDismiss?: boolean; +} + +interface FeedbackModalScreenProps { + visible: boolean; + modalParams: FeedbackModalScreenParams | null; + onHideModal?: () => void; +} + +const FeedbackModalScreen: React.FC = ({ + visible, + modalParams, + onHideModal, +}) => { + const onButtonPressed = useCallback(async () => { + confirmTap(); + + if (!modalParams || !modalParams.onButtonPress) { + console.warn('Modal params not found or onButtonPress not defined'); + return; + } + + try { + await modalParams.onButtonPress(); + } catch (callbackError) { + console.error('Callback error:', callbackError); + } finally { + onHideModal?.(); + } + }, [modalParams, onHideModal]); + + const onClose = useCallback(() => { + impactLight(); + modalParams?.onModalDismiss?.(); + onHideModal?.(); + }, [modalParams, onHideModal]); + + const onSecondaryButtonPress = useCallback(async () => { + if (!modalParams?.onSecondaryButtonPress) { + return; + } + + try { + await modalParams.onSecondaryButtonPress(); + } catch (error) { + console.error('Secondary button callback error:', error); + } finally { + onHideModal?.(); + } + }, [modalParams, onHideModal]); + + if (!modalParams) { + return null; + } + + return ( + + + + + + + {modalParams.preventDismiss ? null : ( + + )} + + + {modalParams.titleText} + + {modalParams.bodyText} + + + + + {modalParams.buttonText} + + {modalParams.secondaryButtonText && ( + + {modalParams.secondaryButtonText} + + )} + + + + + + ); +}; + +const styles = StyleSheet.create({ + description: { + textAlign: 'left', + }, +}); + +export default FeedbackModalScreen; diff --git a/app/src/hooks/useFeedbackAutoHide.ts b/app/src/hooks/useFeedbackAutoHide.ts new file mode 100644 index 000000000..cae240d63 --- /dev/null +++ b/app/src/hooks/useFeedbackAutoHide.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import { useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import { hideFeedbackButton } from '@sentry/react-native'; + +/** + * Hook to automatically hide the Sentry feedback button when the screen loses focus. + * This should be used within screens that have navigation context. + */ +export const useFeedbackAutoHide = () => { + useFocusEffect( + useCallback(() => { + // When screen comes into focus, do nothing (button might be shown by user action) + + // When screen goes out of focus, hide the feedback button + return () => { + hideFeedbackButton(); + }; + }, []), + ); +}; diff --git a/app/src/hooks/useFeedbackModal.ts b/app/src/hooks/useFeedbackModal.ts new file mode 100644 index 000000000..359c30379 --- /dev/null +++ b/app/src/hooks/useFeedbackModal.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + hideFeedbackButton, + showFeedbackButton, + showFeedbackWidget, +} from '@sentry/react-native'; + +import type { FeedbackModalScreenParams } from '@/components/FeedbackModalScreen'; +import { captureFeedback } from '@/Sentry'; + +export type FeedbackType = 'button' | 'widget' | 'custom'; + +export const useFeedbackModal = () => { + const timeoutRef = useRef | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalParams, setModalParams] = + useState(null); + + const showFeedbackModal = useCallback((type: FeedbackType = 'button') => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + switch (type) { + case 'button': + showFeedbackButton(); + break; + case 'widget': + showFeedbackWidget(); + break; + case 'custom': + setIsVisible(true); + break; + default: + showFeedbackButton(); + } + + // we can close the feedback modals(sentry and custom modals), but can't do so for the Feedback button. + // This hides the button after 10 seconds. + if (type === 'button') { + timeoutRef.current = setTimeout(() => { + hideFeedbackButton(); + timeoutRef.current = null; + }, 10000); + } + }, []); + + const hideFeedbackModal = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + hideFeedbackButton(); + + setIsVisible(false); + }, []); + + const showModal = useCallback((params: FeedbackModalScreenParams) => { + setModalParams(params); + setIsModalVisible(true); + }, []); + + const hideModal = useCallback(() => { + setIsModalVisible(false); + setModalParams(null); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, []); + + //used by the custom modal to submit feedback + const submitFeedback = useCallback( + async ( + feedback: string, + category: string, + name?: string, + email?: string, + ) => { + try { + captureFeedback(feedback, { + category, + source: 'feedback_modal', + name, + email, + extra: { + feedback, + category, + name, + email, + timestamp: new Date().toISOString(), + }, + }); + } catch (error) { + console.error('Failed to submit feedback:', error); + } + }, + [], + ); + + return { + isVisible, + showFeedbackModal, + hideFeedbackModal, + submitFeedback, + isModalVisible, + modalParams, + showModal, + hideModal, + }; +}; diff --git a/app/src/layouts/SimpleScrolledTitleLayout.tsx b/app/src/layouts/SimpleScrolledTitleLayout.tsx index adfc8363c..532f46dc6 100644 --- a/app/src/layouts/SimpleScrolledTitleLayout.tsx +++ b/app/src/layouts/SimpleScrolledTitleLayout.tsx @@ -38,17 +38,22 @@ export default function SimpleScrolledTitleLayout({ {header} - + {children} - {footer && {footer}} + {footer && ( + + {footer} + + )} {secondaryButtonText && onSecondaryButtonPress && ( - + {secondaryButtonText} )} - + {/* Anchor the Dismiss button to bottom with only safe area padding */} + Dismiss diff --git a/app/src/providers/feedbackProvider.tsx b/app/src/providers/feedbackProvider.tsx new file mode 100644 index 000000000..3b2024155 --- /dev/null +++ b/app/src/providers/feedbackProvider.tsx @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import type { ReactNode } from 'react'; +import React, { createContext, useContext } from 'react'; + +import FeedbackModal from '@/components/FeedbackModal'; +import type { FeedbackModalScreenParams } from '@/components/FeedbackModalScreen'; +import FeedbackModalScreen from '@/components/FeedbackModalScreen'; +import type { FeedbackType } from '@/hooks/useFeedbackModal'; +import { useFeedbackModal } from '@/hooks/useFeedbackModal'; + +interface FeedbackContextType { + showFeedbackModal: (type?: FeedbackType) => void; + submitFeedback: ( + feedback: string, + category: string, + name?: string, + email?: string, + ) => Promise; + showModal: (params: FeedbackModalScreenParams) => void; +} + +const FeedbackContext = createContext( + undefined, +); + +export const FeedbackProvider: React.FC = ({ + children, +}) => { + const { + isVisible, + showFeedbackModal, + hideFeedbackModal, + submitFeedback, + isModalVisible, + modalParams, + showModal, + hideModal, + } = useFeedbackModal(); + + return ( + + {children} + + + + + + ); +}; + +interface FeedbackProviderProps { + children: ReactNode; +} + +export const useFeedback = () => { + const context = useContext(FeedbackContext); + if (!context) { + throw new Error('useFeedback must be used within a FeedbackProvider'); + } + return context; +}; diff --git a/app/src/screens/passport/PassportNFCScanScreen.tsx b/app/src/screens/passport/PassportNFCScanScreen.tsx index fbc322fb8..49bc5844d 100644 --- a/app/src/screens/passport/PassportNFCScanScreen.tsx +++ b/app/src/screens/passport/PassportNFCScanScreen.tsx @@ -32,13 +32,16 @@ import TextsContainer from '@/components/TextsContainer'; import { BodyText } from '@/components/typography/BodyText'; import { Title } from '@/components/typography/Title'; import { PassportEvents } from '@/consts/analytics'; +import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import NFC_IMAGE from '@/images/nfc.png'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import { useFeedback } from '@/providers/feedbackProvider'; import { storePassportData } from '@/providers/passportDataProvider'; import useUserStore from '@/stores/userStore'; import analytics from '@/utils/analytics'; import { black, slate100, slate400, slate500, white } from '@/utils/colors'; +import { sendFeedbackEmail } from '@/utils/email'; import { dinot } from '@/utils/fonts'; import { buttonTap, @@ -46,9 +49,9 @@ import { feedbackUnsuccessful, impactLight, } from '@/utils/haptic'; -import { registerModalCallbacks } from '@/utils/modalCallbackRegistry'; import { parseScanResponse, scan } from '@/utils/nfcScanner'; import { hasAnyValidRegisteredDocument } from '@/utils/proving/validateDocument'; +import { sanitizeErrorMessage } from '@/utils/utils'; const { trackEvent } = analytics(); @@ -74,6 +77,8 @@ type PassportNFCScanRoute = RouteProp< const PassportNFCScanScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); + const { showModal } = useFeedback(); + useFeedbackAutoHide(); const { passportNumber, dateOfBirth, @@ -87,6 +92,8 @@ const PassportNFCScanScreen: React.FC = () => { const [isNfcSheetOpen, setIsNfcSheetOpen] = useState(false); const [dialogMessage, setDialogMessage] = useState(''); const [nfcMessage, setNfcMessage] = useState(null); + const scanTimeoutRef = useRef | null>(null); + const scanCancelledRef = useRef(false); const animationRef = useRef(null); @@ -94,6 +101,17 @@ const PassportNFCScanScreen: React.FC = () => { animationRef.current?.play(); }, []); + // Cleanup timeout on component unmount + useEffect(() => { + return () => { + scanCancelledRef.current = true; + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + scanTimeoutRef.current = null; + } + }; + }, []); + const goToNFCMethodSelection = useHapticNavigation( 'PassportNFCMethodSelection', ); @@ -106,22 +124,31 @@ const PassportNFCScanScreen: React.FC = () => { goToNFCMethodSelection(); }); + const onReportIssue = useCallback(() => { + sendFeedbackEmail({ + message: 'User reported an issue from NFC scan screen', + origin: 'passport/nfc', + }); + }, []); + const openErrorModal = useCallback( (message: string) => { - const callbackId = registerModalCallbacks({ - onButtonPress: () => {}, - onModalDismiss: goToNFCTrouble, - }); - navigation.navigate('Modal', { + showModal({ titleText: 'NFC Scan Error', bodyText: message, - buttonText: 'Dismiss', + buttonText: 'Report Issue', secondaryButtonText: 'Help', - preventDismiss: true, - callbackId, + preventDismiss: false, + onButtonPress: () => + sendFeedbackEmail({ + message: sanitizeErrorMessage(message), + origin: 'passport/nfc', + }), + onSecondaryButtonPress: goToNFCTrouble, + onModalDismiss: () => {}, }); }, - [navigation, goToNFCTrouble], + [showModal, goToNFCTrouble], ); const checkNfcSupport = useCallback(async () => { @@ -164,7 +191,20 @@ const PassportNFCScanScreen: React.FC = () => { if (isNfcEnabled) { setIsNfcSheetOpen(true); // Add timestamp when scan starts + scanCancelledRef.current = false; const scanStartTime = Date.now(); + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + scanTimeoutRef.current = null; + } + scanTimeoutRef.current = setTimeout(() => { + scanCancelledRef.current = true; + trackEvent(PassportEvents.NFC_SCAN_FAILED, { + error: 'timeout', + }); + openErrorModal('Scan timed out. Please try again.'); + setIsNfcSheetOpen(false); + }, 30000); try { const { canNumber, useCan, skipPACE, skipCA, extendedMode } = @@ -182,6 +222,11 @@ const PassportNFCScanScreen: React.FC = () => { usePacePolling: isPacePolling, }); + // Check if scan was cancelled by timeout + if (scanCancelledRef.current) { + return; + } + const scanDurationSeconds = ( (Date.now() - scanStartTime) / 1000 @@ -201,7 +246,9 @@ const PassportNFCScanScreen: React.FC = () => { } catch (e: unknown) { console.error('Parsing NFC Response Unsuccessful'); trackEvent(PassportEvents.NFC_RESPONSE_PARSE_FAILED, { - error: e instanceof Error ? e.message : String(e), + error: sanitizeErrorMessage( + e instanceof Error ? e.message : String(e), + ), }); return; } @@ -254,15 +301,30 @@ const PassportNFCScanScreen: React.FC = () => { } // Feels better somehow await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check if scan was cancelled by timeout before navigating + if (scanCancelledRef.current) { + return; + } navigation.navigate('ConfirmBelongingScreen', {}); } catch (e: unknown) { + // Check if scan was cancelled by timeout + if (scanCancelledRef.current) { + return; + } console.error('Passport Parsed Failed:', e); trackEvent(PassportEvents.PASSPORT_PARSE_FAILED, { - error: e instanceof Error ? e.message : String(e), + error: sanitizeErrorMessage( + e instanceof Error ? e.message : String(e), + ), }); return; } } catch (e: unknown) { + // Check if scan was cancelled by timeout + if (scanCancelledRef.current) { + return; + } const scanDurationSeconds = ( (Date.now() - scanStartTime) / 1000 @@ -270,11 +332,17 @@ const PassportNFCScanScreen: React.FC = () => { console.error('NFC Scan Unsuccessful:', e); const message = e instanceof Error ? e.message : String(e); trackEvent(PassportEvents.NFC_SCAN_FAILED, { - error: message, + error: sanitizeErrorMessage(message), duration_seconds: parseFloat(scanDurationSeconds), }); openErrorModal(message); + // We deliberately avoid opening any external feedback widgets here; + // users can send feedback via the email action in the modal. } finally { + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + scanTimeoutRef.current = null; + } setIsNfcSheetOpen(false); } } else if (isNfcSupported) { @@ -355,8 +423,23 @@ const PassportNFCScanScreen: React.FC = () => { return () => { subscription.remove(); + // Clear scan timeout when component loses focus + scanCancelledRef.current = true; + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + scanTimeoutRef.current = null; + } }; } + + // For iOS or when no emitter, still handle timeout cleanup on blur + return () => { + scanCancelledRef.current = true; + if (scanTimeoutRef.current) { + clearTimeout(scanTimeoutRef.current); + scanTimeoutRef.current = null; + } + }; }, [checkNfcSupport]), ); @@ -468,6 +551,9 @@ const PassportNFCScanScreen: React.FC = () => { > Cancel + + Report Issue + )} diff --git a/app/src/screens/passport/PassportNFCTroubleScreen.tsx b/app/src/screens/passport/PassportNFCTroubleScreen.tsx index bc193d0c5..d7fb9d74c 100644 --- a/app/src/screens/passport/PassportNFCTroubleScreen.tsx +++ b/app/src/screens/passport/PassportNFCTroubleScreen.tsx @@ -4,13 +4,16 @@ import React, { useEffect } from 'react'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { YStack } from 'tamagui'; +import { SecondaryButton } from '@/components/buttons/SecondaryButton'; import type { TipProps } from '@/components/Tips'; import Tips from '@/components/Tips'; import { Caption } from '@/components/typography/Caption'; +import { useFeedbackAutoHide } from '@/hooks/useFeedbackAutoHide'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout'; import analytics from '@/utils/analytics'; import { slate500 } from '@/utils/colors'; +import { sendFeedbackEmail } from '@/utils/email'; const { flush: flushAnalytics } = analytics(); @@ -46,6 +49,7 @@ const PassportNFCTrouble: React.FC = () => { const goToNFCMethodSelection = useHapticNavigation( 'PassportNFCMethodSelection', ); + useFeedbackAutoHide(); // error screen, flush analytics useEffect(() => { @@ -65,6 +69,22 @@ const PassportNFCTrouble: React.FC = () => { onDismiss={go} secondaryButtonText="Open NFC Options" onSecondaryButtonPress={goToNFCMethodSelection} + footer={ + // Add top padding before buttons and normalize spacing + + + sendFeedbackEmail({ + message: 'User reported an issue from NFC trouble screen', + origin: 'passport/nfc-trouble', + }) + } + marginBottom={0} + > + Report Issue + + + } > => { + const deviceInfo = [ + ['device', `${Platform.OS}@${Platform.Version}`], + ['app', `v${version}`], + [ + 'locales', + getLocales() + .map(locale => `${locale.languageCode}-${locale.countryCode}`) + .join(','), + ], + ['country', getCountry()], + ['tz', getTimeZone()], + ['ts', new Date().toISOString()], + ['origin', origin], + ['error', sanitizeErrorMessage(message)], + ] as [string, string][]; + + const body = `Please describe the issue you're experiencing: + +--- +Technical Details (do not modify): +${deviceInfo.map(([k, v]) => `${k}=${v}`).join('\n')} +---`; + + await Linking.openURL( + `mailto:${recipient}?subject=${encodeURIComponent( + subject, + )}&body=${encodeURIComponent(body)}`, + ); +}; diff --git a/app/src/utils/utils.ts b/app/src/utils/utils.ts index c70a287be..de8ee6b0d 100644 --- a/app/src/utils/utils.ts +++ b/app/src/utils/utils.ts @@ -16,3 +16,14 @@ export function checkScannedInfo( } return true; } + +// Redacts 9+ consecutive digits and MRZ-like blocks to reduce PII exposure +export const sanitizeErrorMessage = (msg: string): string => { + try { + return msg + .replace(/\b\d{9,}\b/g, '[REDACTED]') + .replace(/[A-Z0-9<]{30,}/g, '[MRZ_REDACTED]'); + } catch { + return 'redacted'; + } +}; diff --git a/app/tests/src/utils/sanitizeErrorMessage.test.ts b/app/tests/src/utils/sanitizeErrorMessage.test.ts new file mode 100644 index 000000000..3648c0396 --- /dev/null +++ b/app/tests/src/utils/sanitizeErrorMessage.test.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 + +import { sanitizeErrorMessage } from '@/utils/utils'; + +describe('sanitizeErrorMessage', () => { + it('redacts sequences of 9+ digits', () => { + const input = 'Passport number 123456789 should be hidden'; + const result = sanitizeErrorMessage(input); + expect(result).toBe('Passport number [REDACTED] should be hidden'); + }); + + it('does not redact short numbers (<9 digits)', () => { + const input = 'Retry in 120 seconds. Code 12345678 only'; + const result = sanitizeErrorMessage(input); + expect(result).toBe('Retry in 120 seconds. Code 12345678 only'); + }); + + it('redacts MRZ-like long blocks (>=30 chars of A-Z0-9<)', () => { + const mrzLike = 'P { + const line1 = 'A'.repeat(44); + const line2 = 'B'.repeat(44); + const suffix = ' context'; + const input = `${line1}\n${line2}${suffix}`; + const result = sanitizeErrorMessage(input); + expect(result).toBe('[MRZ_REDACTED]\n[MRZ_REDACTED]' + suffix); + }); + + it('redacts multiple occurrences in the same string', () => { + const input = 'ids 123456789 and 987654321 are present'; + const result = sanitizeErrorMessage(input); + expect(result).toBe('ids [REDACTED] and [REDACTED] are present'); + }); + + it('returns "redacted" on unexpected errors', () => { + // Simulate a failure by monkey-patching String.prototype.replace temporarily + const originalReplace = (String.prototype as any).replace; + (String.prototype as any).replace = () => { + throw new Error('boom'); + }; + try { + const result = sanitizeErrorMessage('any'); + expect(result).toBe('redacted'); + } finally { + (String.prototype as any).replace = originalReplace; + } + }); +});