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;
+ }
+ });
+});