mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Merge pull request #1976 from selfxyz/release/staging-2026-04-14
Release to Staging v2.9.17 - 2026-04-14
This commit is contained in:
@@ -220,6 +220,11 @@ The `specs/` folder contains architecture and implementation specs for the Self
|
||||
- Use suffixed variants (for example `SPEC-<TOPIC>.md`) only when multiple specs of the same type are required in the same folder.
|
||||
- When renaming/moving spec files, update all references in `specs/`, `AGENTS.md`, and `CLAUDE.md` in the same change.
|
||||
|
||||
### Spec Writing Rules
|
||||
|
||||
- Qualify coverage claims. Distinguish unit-tested/shared-utility coverage from handler-level, integration, or end-to-end coverage; do not say "tested" without naming the actual coverage level.
|
||||
- Flag invariant departures. If a spec intentionally departs from active architecture rules or repo invariants, call out the conflict explicitly, justify the departure, and list the docs that must be updated if the direction is accepted.
|
||||
|
||||
**Start here:** [specs/README.md](./specs/README.md) — table of contents and reading order.
|
||||
|
||||
Key files:
|
||||
|
||||
@@ -78,6 +78,8 @@ Specs are agent-executable prompts. A new Claude Code session with no prior cont
|
||||
- **One spec = one PR.** Target the PR size from Key Rules (1k–3k LOC). If a spec would exceed that, split it.
|
||||
- **Mark items as required vs optional.** Don't let agents infer priority.
|
||||
- **Include out-of-scope sections.** These are as important as in-scope sections for preventing drift.
|
||||
- **Qualify coverage claims precisely.** "Complete, tested" means handler-level integration tests pass end-to-end. If only shared parsers or utilities are tested, say that. Overclaimed coverage propagates into execution plans and skips real testing work.
|
||||
- **Flag invariant departures explicitly.** If a spec's approach conflicts with an active rule in CLAUDE.md, OVERVIEW.md, or a sibling workstream SPEC.md, the spec must call out the conflict, justify the departure, and list the parent docs that need updating. Silent contradictions cause repo-wide drift.
|
||||
- **Use `--remote` for medium+ work.** Medium and large specs benefit from `claude --remote` so work continues in the background.
|
||||
|
||||
### Audit Pipeline Skills
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 216;
|
||||
CURRENT_PROJECT_VERSION = 217;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -620,7 +620,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 216;
|
||||
CURRENT_PROJECT_VERSION = 217;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
|
||||
|
||||
346
app/src/components/PointHistoryList.tsx
Normal file
346
app/src/components/PointHistoryList.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { Card, Text, View, XStack, YStack } from 'tamagui';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { PointEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate200,
|
||||
slate300,
|
||||
slate400,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import HeartIcon from '@/assets/icons/heart.svg';
|
||||
import StarBlackIcon from '@/assets/icons/star_black.svg';
|
||||
import type { PointEvent } from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
data: PointEvent[];
|
||||
};
|
||||
|
||||
export type PointHistoryListProps = {
|
||||
ListHeaderComponent?:
|
||||
| React.ComponentType<Record<string, unknown>>
|
||||
| React.ReactElement
|
||||
| null;
|
||||
onLayout?: () => void;
|
||||
};
|
||||
|
||||
const TIME_PERIODS = {
|
||||
TODAY: 'TODAY',
|
||||
THIS_WEEK: 'THIS WEEK',
|
||||
THIS_MONTH: 'THIS MONTH',
|
||||
MONTH_NAME: (date: Date): string => {
|
||||
return date.toLocaleString('default', { month: 'long' }).toUpperCase();
|
||||
},
|
||||
OLDER: 'OLDER',
|
||||
};
|
||||
|
||||
const getIconForEventType = (type: PointEvent['type']) => {
|
||||
switch (type) {
|
||||
case 'disclosure':
|
||||
return <StarBlackIcon width={20} height={20} />;
|
||||
default:
|
||||
return <HeartIcon width={20} height={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const PointHistoryList: React.FC<PointHistoryListProps> = ({
|
||||
ListHeaderComponent,
|
||||
onLayout,
|
||||
}) => {
|
||||
const selfClient = useSelfClient();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const pointEvents = usePointEventStore(state => state.getAllPointEvents());
|
||||
const isLoading = usePointEventStore(state => state.isLoading);
|
||||
const refreshPoints = usePointEventStore(state => state.refreshPoints);
|
||||
const refreshIncomingPoints = usePointEventStore(
|
||||
state => state.refreshIncomingPoints,
|
||||
);
|
||||
const loadDisclosureEvents = usePointEventStore(
|
||||
state => state.loadDisclosureEvents,
|
||||
);
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateFull = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getTimePeriod = useCallback((timestamp: number): string => {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(timestamp);
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
);
|
||||
const startOfThisWeek = new Date(startOfToday);
|
||||
startOfThisWeek.setDate(startOfToday.getDate() - startOfToday.getDay());
|
||||
const startOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
|
||||
if (eventDate >= startOfToday) {
|
||||
return TIME_PERIODS.TODAY;
|
||||
} else if (eventDate >= startOfThisWeek) {
|
||||
return TIME_PERIODS.THIS_WEEK;
|
||||
} else if (eventDate >= startOfThisMonth) {
|
||||
return TIME_PERIODS.THIS_MONTH;
|
||||
} else if (eventDate >= startOfLastMonth) {
|
||||
return TIME_PERIODS.MONTH_NAME(eventDate);
|
||||
} else {
|
||||
return TIME_PERIODS.OLDER;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupedEvents = useMemo(() => {
|
||||
const groups: Record<string, PointEvent[]> = {};
|
||||
|
||||
[
|
||||
TIME_PERIODS.TODAY,
|
||||
TIME_PERIODS.THIS_WEEK,
|
||||
TIME_PERIODS.THIS_MONTH,
|
||||
TIME_PERIODS.OLDER,
|
||||
].forEach(period => {
|
||||
groups[period] = [];
|
||||
});
|
||||
|
||||
const monthGroups = new Set<string>();
|
||||
|
||||
pointEvents.forEach(event => {
|
||||
const period = getTimePeriod(event.timestamp);
|
||||
if (
|
||||
period !== TIME_PERIODS.TODAY &&
|
||||
period !== TIME_PERIODS.THIS_WEEK &&
|
||||
period !== TIME_PERIODS.THIS_MONTH &&
|
||||
period !== TIME_PERIODS.OLDER
|
||||
) {
|
||||
monthGroups.add(period);
|
||||
if (!groups[period]) {
|
||||
groups[period] = [];
|
||||
}
|
||||
}
|
||||
groups[period].push(event);
|
||||
});
|
||||
|
||||
const sections: Section[] = [];
|
||||
[
|
||||
TIME_PERIODS.TODAY,
|
||||
TIME_PERIODS.THIS_WEEK,
|
||||
TIME_PERIODS.THIS_MONTH,
|
||||
].forEach(period => {
|
||||
if (groups[period] && groups[period].length > 0) {
|
||||
sections.push({ title: period, data: groups[period] });
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(monthGroups)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(groups[b][0].timestamp).getMonth() -
|
||||
new Date(groups[a][0].timestamp).getMonth(),
|
||||
)
|
||||
.forEach(month => {
|
||||
sections.push({ title: month, data: groups[month] });
|
||||
});
|
||||
|
||||
if (groups[TIME_PERIODS.OLDER] && groups[TIME_PERIODS.OLDER].length > 0) {
|
||||
sections.push({
|
||||
title: TIME_PERIODS.OLDER,
|
||||
data: groups[TIME_PERIODS.OLDER],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [pointEvents, getTimePeriod]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
}: {
|
||||
item: PointEvent;
|
||||
index: number;
|
||||
section: Section;
|
||||
}) => {
|
||||
const borderRadiusSize = 16;
|
||||
const isFirstItem = index === 0;
|
||||
const isLastItem = index === section.data.length - 1;
|
||||
|
||||
return (
|
||||
<View paddingHorizontal={5}>
|
||||
<YStack gap={8}>
|
||||
<Card
|
||||
borderTopLeftRadius={isFirstItem ? borderRadiusSize : 0}
|
||||
borderTopRightRadius={isFirstItem ? borderRadiusSize : 0}
|
||||
borderBottomLeftRadius={isLastItem ? borderRadiusSize : 0}
|
||||
borderBottomRightRadius={isLastItem ? borderRadiusSize : 0}
|
||||
borderBottomWidth={1}
|
||||
borderColor={slate200}
|
||||
padded
|
||||
backgroundColor={white}
|
||||
>
|
||||
<XStack alignItems="center" gap={12}>
|
||||
<View height={46} alignItems="center" justifyContent="center">
|
||||
{getIconForEventType(item.type)}
|
||||
</View>
|
||||
<YStack flex={1}>
|
||||
<Text
|
||||
fontSize={16}
|
||||
color={black}
|
||||
fontWeight="500"
|
||||
fontFamily={dinot}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
fontFamily={plexMono}
|
||||
color={slate400}
|
||||
fontSize={14}
|
||||
marginTop={2}
|
||||
>
|
||||
{formatDateFull(item.timestamp)} •{' '}
|
||||
{formatDate(item.timestamp)}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text
|
||||
fontSize={18}
|
||||
color={blue600}
|
||||
fontWeight="600"
|
||||
fontFamily={dinot}
|
||||
>
|
||||
+{item.points}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderSectionHeader = useCallback(
|
||||
({ section }: { section: Section }) => {
|
||||
return (
|
||||
<View
|
||||
paddingHorizontal={20}
|
||||
backgroundColor={slate50}
|
||||
marginTop={20}
|
||||
marginBottom={12}
|
||||
gap={12}
|
||||
>
|
||||
<Text
|
||||
color={slate500}
|
||||
fontSize={15}
|
||||
fontWeight="500"
|
||||
letterSpacing={0.6}
|
||||
fontFamily={dinot}
|
||||
>
|
||||
{section.title.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
selfClient.trackEvent(PointEvents.REFRESH_HISTORY);
|
||||
setRefreshing(true);
|
||||
Promise.all([
|
||||
refreshPoints(),
|
||||
refreshIncomingPoints(),
|
||||
loadDisclosureEvents(),
|
||||
]).finally(() => setRefreshing(false));
|
||||
}, [selfClient, refreshPoints, refreshIncomingPoints, loadDisclosureEvents]);
|
||||
|
||||
const keyExtractor = useCallback((item: PointEvent) => item.id, []);
|
||||
|
||||
const renderEmptyComponent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<ActivityIndicator size="large" color={slate300} />
|
||||
<Text color={slate300} marginTop={16}>
|
||||
Loading point history...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text color={slate300}>No point history available yet.</Text>
|
||||
<Text color={slate500} fontSize={14} marginTop={8} textAlign="center">
|
||||
Start earning points by completing actions!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
sections={groupedEvents}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
groupedEvents.length === 0 && styles.emptyList,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
style={{ marginHorizontal: 15, marginBottom: 25 }}
|
||||
onLayout={onLayout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
emptyList: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 5,
|
||||
},
|
||||
});
|
||||
|
||||
export default PointHistoryList;
|
||||
553
app/src/components/navbar/Points.tsx
Normal file
553
app/src/components/navbar/Points.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
import { Image, Text, View, XStack, YStack, ZStack } from 'tamagui';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { HelpCircle } from '@tamagui/lucide-icons';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { PointEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate200,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import BellWhiteIcon from '@/assets/icons/bell_white.svg';
|
||||
import ClockIcon from '@/assets/icons/clock.svg';
|
||||
import LockWhiteIcon from '@/assets/icons/lock_white.svg';
|
||||
import StarBlackIcon from '@/assets/icons/star_black.svg';
|
||||
import LogoInversed from '@/assets/images/logo_inversed.svg';
|
||||
import MajongImage from '@/assets/images/majong.png';
|
||||
import { PointHistoryList } from '@/components/PointHistoryList';
|
||||
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
|
||||
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { trackScreenView } from '@/services/analytics';
|
||||
import {
|
||||
isTopicSubscribed,
|
||||
requestNotificationPermission,
|
||||
subscribeToTopics,
|
||||
} from '@/services/notifications/notificationService';
|
||||
import {
|
||||
formatTimeUntilDate,
|
||||
POINT_VALUES,
|
||||
recordBackupPointEvent,
|
||||
recordNotificationPointEvent,
|
||||
} from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
const Points: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [isGeneralSubscribed, setIsGeneralSubscribed] = useState(false);
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const incomingPoints = useIncomingPoints();
|
||||
const { amount: points } = usePoints();
|
||||
const loadEvents = usePointEventStore(state => state.loadEvents);
|
||||
const { hasCompletedBackupForPoints, setBackupForPointsCompleted } =
|
||||
useSettingStore();
|
||||
const [isBackingUp, setIsBackingUp] = useState(false);
|
||||
|
||||
// Guard: Validate that user has registered a document and completed points disclosure
|
||||
usePointsGuardrail();
|
||||
|
||||
// Track NavBar view analytics
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
trackScreenView('Points NavBar', {
|
||||
screenName: 'Points NavBar',
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
|
||||
const onHelpButtonPress = () => {
|
||||
navigation.navigate('PointsInfo');
|
||||
};
|
||||
|
||||
//TODO - uncomment after merging - https://github.com/selfxyz/self/pull/1363/
|
||||
// useEffect(() => {
|
||||
// const backupEvent = usePointEventStore
|
||||
// .getState()
|
||||
// .events.find(
|
||||
// event => event.type === 'backup' && event.status === 'completed',
|
||||
// );
|
||||
|
||||
// if (backupEvent && !hasCompletedBackupForPoints) {
|
||||
// setBackupForPointsCompleted();
|
||||
// }
|
||||
// }, [setBackupForPointsCompleted, hasCompletedBackupForPoints]);
|
||||
|
||||
// Track if we should check for backup completion on next focus
|
||||
const shouldCheckBackupRef = React.useRef(false);
|
||||
|
||||
// Detect when returning from backup screen and record points if backup was completed
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
const { cloudBackupEnabled, turnkeyBackupEnabled } =
|
||||
useSettingStore.getState();
|
||||
const currentHasCompletedBackup =
|
||||
useSettingStore.getState().hasCompletedBackupForPoints;
|
||||
|
||||
// Only check if we explicitly set the flag (when navigating to backup settings)
|
||||
// This prevents false triggers when returning from other flows (like notification permissions)
|
||||
if (
|
||||
shouldCheckBackupRef.current &&
|
||||
(cloudBackupEnabled || turnkeyBackupEnabled) &&
|
||||
!currentHasCompletedBackup
|
||||
) {
|
||||
const recordPoints = async () => {
|
||||
try {
|
||||
const response = await recordBackupPointEvent();
|
||||
|
||||
if (response.success) {
|
||||
useSettingStore.getState().setBackupForPointsCompleted();
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS);
|
||||
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Success!',
|
||||
bodyText: `Account backed up successfully! You earned ${POINT_VALUES.backup} points.\n\nPoints will be distributed to your wallet on the next Sunday at noon UTC.`,
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
'Error recording backup points after return:',
|
||||
response.error,
|
||||
);
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
|
||||
}
|
||||
} catch (error) {
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
|
||||
console.error('Error recording backup points after return:', error);
|
||||
}
|
||||
};
|
||||
|
||||
recordPoints();
|
||||
}
|
||||
|
||||
// Reset the flag after checking
|
||||
shouldCheckBackupRef.current = false;
|
||||
}, [navigation, selfClient]),
|
||||
);
|
||||
|
||||
// Mock function to check if user has backed up their account
|
||||
const hasUserBackedUpAccount = (): boolean => {
|
||||
return hasCompletedBackupForPoints;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSubscription = async () => {
|
||||
const subscribed = await isTopicSubscribed('general');
|
||||
setIsGeneralSubscribed(subscribed);
|
||||
};
|
||||
checkSubscription();
|
||||
}, []);
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
if (isEnabling) {
|
||||
return;
|
||||
}
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION);
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
const granted = await requestNotificationPermission();
|
||||
if (granted) {
|
||||
const result = await subscribeToTopics(['general']);
|
||||
if (result.successes.length > 0) {
|
||||
const response = await recordNotificationPointEvent();
|
||||
if (response.success) {
|
||||
setIsGeneralSubscribed(true);
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_SUCCESS);
|
||||
|
||||
navigation.navigate('Gratification', {
|
||||
points: POINT_VALUES.notification,
|
||||
});
|
||||
} else {
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
|
||||
reason: 'Failed to record points',
|
||||
});
|
||||
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Verification Failed',
|
||||
bodyText:
|
||||
response.error ||
|
||||
'Failed to register points. Please try again.',
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
|
||||
reason: 'Subscription failed',
|
||||
});
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Error',
|
||||
bodyText: `Failed to enable: ${result.failures.map(f => f.error).join(', ')}`,
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
|
||||
reason: 'Permission denied',
|
||||
});
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Permission Required',
|
||||
bodyText:
|
||||
'Could not enable notifications. Please enable them in your device Settings.',
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
selfClient.trackEvent(PointEvents.EARN_NOTIFICATION_FAILED, {
|
||||
reason: 'Exception occurred',
|
||||
});
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Error',
|
||||
bodyText:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to enable notifications',
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupSecret = async () => {
|
||||
if (isBackingUp) {
|
||||
return;
|
||||
}
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP);
|
||||
|
||||
const { cloudBackupEnabled, turnkeyBackupEnabled } =
|
||||
useSettingStore.getState();
|
||||
|
||||
// If either backup method is already enabled, just record points
|
||||
if (cloudBackupEnabled || turnkeyBackupEnabled) {
|
||||
setIsBackingUp(true);
|
||||
try {
|
||||
// this will add event to store and the new event will then trigger useIncomingPoints hook to refetch incoming points
|
||||
const response = await recordBackupPointEvent();
|
||||
|
||||
if (response.success) {
|
||||
setBackupForPointsCompleted();
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_SUCCESS);
|
||||
|
||||
navigation.navigate('Gratification', {
|
||||
points: POINT_VALUES.backup,
|
||||
});
|
||||
} else {
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Verification Failed',
|
||||
bodyText:
|
||||
response.error || 'Failed to register points. Please try again.',
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
selfClient.trackEvent(PointEvents.EARN_BACKUP_FAILED);
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {},
|
||||
onModalDismiss: () => {},
|
||||
});
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Error',
|
||||
bodyText:
|
||||
error instanceof Error ? error.message : 'Failed to backup account',
|
||||
buttonText: 'OK',
|
||||
callbackId,
|
||||
});
|
||||
} finally {
|
||||
setIsBackingUp(false);
|
||||
}
|
||||
} else {
|
||||
// Navigate to backup screen and return to Points after backup completes
|
||||
// Set flag to check for backup completion when we return
|
||||
shouldCheckBackupRef.current = true;
|
||||
navigation.navigate('CloudBackupSettings', { returnToScreen: 'Points' });
|
||||
}
|
||||
};
|
||||
|
||||
const ListHeader = (
|
||||
<YStack paddingHorizontal={5} gap={20} paddingTop={20}>
|
||||
<YStack style={styles.pointsCard}>
|
||||
<Pressable style={styles.helpButton} onPress={onHelpButtonPress}>
|
||||
<HelpCircle size={32} color={blue600} />
|
||||
</Pressable>
|
||||
<YStack style={styles.pointsCardContent}>
|
||||
<View style={styles.logoContainer}>
|
||||
<LogoInversed width={33} height={33} />
|
||||
</View>
|
||||
<YStack gap={12} alignItems="center">
|
||||
<XStack gap={4} alignItems="center">
|
||||
<Text style={styles.pointsTitle}>{`${points} Self points`}</Text>
|
||||
</XStack>
|
||||
<Text style={styles.pointsDescription}>
|
||||
Earn points by referring friends, disclosing proof requests, and
|
||||
more.
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
{incomingPoints.amount > 0 && incomingPoints.expectedDate && (
|
||||
<XStack style={styles.incomingPointsBar}>
|
||||
<ClockIcon width={16} height={16} />
|
||||
<Text style={styles.incomingPointsAmount}>
|
||||
{`${incomingPoints.amount} incoming points`}
|
||||
</Text>
|
||||
<Text style={styles.incomingPointsTime}>
|
||||
{`Expected in ${formatTimeUntilDate(incomingPoints.expectedDate)}`}
|
||||
</Text>
|
||||
</XStack>
|
||||
)}
|
||||
</YStack>
|
||||
{!isGeneralSubscribed && (
|
||||
<Pressable onPress={handleEnableNotifications} disabled={isEnabling}>
|
||||
<XStack
|
||||
style={[styles.actionCard, { opacity: isEnabling ? 0.5 : 1 }]}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<BellWhiteIcon width={30} height={26} />
|
||||
</View>
|
||||
<YStack gap={4} justifyContent="center">
|
||||
<Text style={styles.actionTitle}>
|
||||
{isEnabling
|
||||
? 'Enabling notifications...'
|
||||
: 'Turn on push notifications'}
|
||||
</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Earn {POINT_VALUES.notification} points
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
)}
|
||||
{!hasUserBackedUpAccount() && (
|
||||
<Pressable onPress={handleBackupSecret} disabled={isBackingUp}>
|
||||
<XStack
|
||||
style={[styles.actionCard, { opacity: isBackingUp ? 0.5 : 1 }]}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<LockWhiteIcon width={30} height={26} />
|
||||
</View>
|
||||
<YStack gap={4} justifyContent="center">
|
||||
<Text style={styles.actionTitle}>
|
||||
{isBackingUp ? 'Processing backup...' : 'Backup your account'}
|
||||
</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Earn {POINT_VALUES.backup} points
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
selfClient.trackEvent(PointEvents.EARN_REFERRAL);
|
||||
navigation.navigate('Referral');
|
||||
}}
|
||||
>
|
||||
<YStack style={styles.referralCard}>
|
||||
<ZStack style={styles.referralImageContainer}>
|
||||
<Image source={MajongImage} style={styles.referralImage} />
|
||||
<StarBlackIcon
|
||||
width={24}
|
||||
height={24}
|
||||
style={styles.referralStarIcon}
|
||||
/>
|
||||
</ZStack>
|
||||
<YStack padding={16} paddingBottom={32} gap={10}>
|
||||
<Text style={styles.referralTitle}>
|
||||
Refer friends and earn rewards
|
||||
</Text>
|
||||
<Text style={styles.referralLink}>Refer now</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={slate50}>
|
||||
<PointHistoryList ListHeaderComponent={ListHeader} />
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pointsCard: {
|
||||
backgroundColor: white,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: slate200,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pointsCardContent: {
|
||||
paddingVertical: 30,
|
||||
paddingHorizontal: 40,
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: slate200,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: white,
|
||||
},
|
||||
pointsTitle: {
|
||||
color: black,
|
||||
textAlign: 'center',
|
||||
fontFamily: dinot,
|
||||
fontWeight: '500',
|
||||
fontSize: 32,
|
||||
lineHeight: 32,
|
||||
letterSpacing: -1,
|
||||
},
|
||||
pointsDescription: {
|
||||
color: black,
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
incomingPointsBar: {
|
||||
backgroundColor: slate50,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: slate200,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
incomingPointsAmount: {
|
||||
flex: 1,
|
||||
fontFamily: dinot,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
color: black,
|
||||
},
|
||||
incomingPointsTime: {
|
||||
fontFamily: dinot,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
color: blue600,
|
||||
},
|
||||
actionCard: {
|
||||
gap: 22,
|
||||
backgroundColor: white,
|
||||
padding: 16,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
borderColor: slate200,
|
||||
},
|
||||
actionIconContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: black,
|
||||
},
|
||||
actionTitle: {
|
||||
color: black,
|
||||
fontFamily: dinot,
|
||||
fontWeight: '500',
|
||||
fontSize: 16,
|
||||
},
|
||||
actionSubtitle: {
|
||||
color: slate500,
|
||||
fontFamily: dinot,
|
||||
fontSize: 14,
|
||||
},
|
||||
referralCard: {
|
||||
height: 270,
|
||||
backgroundColor: white,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: slate200,
|
||||
},
|
||||
referralImageContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: slate200,
|
||||
height: 170,
|
||||
},
|
||||
referralImage: {
|
||||
width: '80%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
referralStarIcon: {
|
||||
marginLeft: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
referralTitle: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
color: black,
|
||||
},
|
||||
referralLink: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
color: blue600,
|
||||
},
|
||||
helpButton: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default Points;
|
||||
60
app/src/components/navbar/PointsNavBar.tsx
Normal file
60
app/src/components/navbar/PointsNavBar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { NativeStackHeaderProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Text, View } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { black, slate50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
export const PointsNavBar = (props: NativeStackHeaderProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const closeButtonWidth = 50;
|
||||
|
||||
return (
|
||||
<NavBar.Container
|
||||
backgroundColor={slate50}
|
||||
barStyle={'dark'}
|
||||
justifyContent="space-between"
|
||||
paddingTop={Math.max(insets.top, 15) + extraYPadding}
|
||||
paddingBottom={10}
|
||||
paddingHorizontal={20}
|
||||
>
|
||||
<NavBar.LeftAction
|
||||
component="close"
|
||||
color={black}
|
||||
onPress={() => {
|
||||
buttonTap();
|
||||
props.navigation.navigate('Home');
|
||||
}}
|
||||
/>
|
||||
<View flex={1} alignItems="center" justifyContent="center">
|
||||
<Text
|
||||
color={black}
|
||||
fontSize={15}
|
||||
fontWeight="500"
|
||||
fontFamily="DINOT-Medium"
|
||||
textAlign="center"
|
||||
style={{
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Self Points
|
||||
</Text>
|
||||
</View>
|
||||
<NavBar.RightAction
|
||||
component={
|
||||
// Spacer to balance the close button and center the title
|
||||
<View style={{ width: closeButtonWidth }} />
|
||||
}
|
||||
/>
|
||||
</NavBar.Container>
|
||||
);
|
||||
};
|
||||
200
app/src/hooks/useEarnPointsFlow.ts
Normal file
200
app/src/hooks/useEarnPointsFlow.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
POINT_VALUES,
|
||||
pointsSelfApp,
|
||||
} from '@/services/points';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { registerModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
type UseEarnPointsFlowParams = {
|
||||
hasReferrer: boolean;
|
||||
isReferralConfirmed: boolean | undefined;
|
||||
};
|
||||
|
||||
export const useEarnPointsFlow = ({
|
||||
hasReferrer,
|
||||
isReferralConfirmed,
|
||||
}: UseEarnPointsFlowParams) => {
|
||||
const selfClient = useSelfClient();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { registerReferral } = useRegisterReferral();
|
||||
const referrer = useUserStore(state => state.deepLinkReferrer);
|
||||
|
||||
const navigateToPointsProof = useCallback(async () => {
|
||||
const selfApp = await pointsSelfApp();
|
||||
selfClient.getSelfAppState().setSelfApp(selfApp);
|
||||
|
||||
// Use setTimeout to ensure modal dismisses before navigating
|
||||
setTimeout(() => {
|
||||
navigation.navigate('ProvingScreenRouter');
|
||||
}, 100);
|
||||
}, [selfClient, navigation]);
|
||||
|
||||
const showIdentityVerificationModal = useCallback(() => {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {
|
||||
// Use setTimeout to ensure modal dismisses before navigating
|
||||
setTimeout(() => {
|
||||
navigation.navigate('CountryPicker');
|
||||
}, 100);
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
if (hasReferrer) {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Identity Verification Required',
|
||||
bodyText:
|
||||
'To access Self Points, you need to register an identity document with Self first. This helps us verify your identity and keep your points secure.',
|
||||
buttonText: 'Verify Identity',
|
||||
secondaryButtonText: 'Not Now',
|
||||
callbackId,
|
||||
});
|
||||
}, [hasReferrer, navigation]);
|
||||
|
||||
const showPointsDisclosureModal = useCallback(() => {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {
|
||||
navigateToPointsProof();
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
if (hasReferrer) {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Points Disclosure Required',
|
||||
bodyText:
|
||||
'To access Self Points, you need to complete the points disclosure first. This helps us verify your identity and keep your points secure.',
|
||||
buttonText: 'Complete Points Disclosure',
|
||||
secondaryButtonText: 'Not Now',
|
||||
callbackId,
|
||||
});
|
||||
}, [hasReferrer, navigation, navigateToPointsProof]);
|
||||
|
||||
const showPointsInfoScreen = useCallback(() => {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => {
|
||||
showPointsDisclosureModal();
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
if (hasReferrer) {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
navigation.navigate('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId,
|
||||
});
|
||||
}, [hasReferrer, navigation, showPointsDisclosureModal]);
|
||||
|
||||
const handleReferralFlow = useCallback(async () => {
|
||||
if (!referrer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showReferralErrorModal = (errorMessage: string) => {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: async () => {
|
||||
await handleReferralFlow();
|
||||
},
|
||||
onModalDismiss: () => {
|
||||
// Clear referrer when user dismisses to prevent retry loop
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
},
|
||||
});
|
||||
|
||||
navigation.navigate('Modal', {
|
||||
titleText: 'Referral Registration Failed',
|
||||
bodyText: `We couldn't register your referral at this time. ${errorMessage}. You can try again or dismiss this message.`,
|
||||
buttonText: 'Try Again',
|
||||
secondaryButtonText: 'Dismiss',
|
||||
callbackId,
|
||||
});
|
||||
};
|
||||
|
||||
const store = useUserStore.getState();
|
||||
// Check if already registered to avoid duplicate calls
|
||||
if (!store.isReferrerRegistered(referrer)) {
|
||||
const result = await registerReferral(referrer);
|
||||
if (result.success) {
|
||||
store.markReferrerAsRegistered(referrer);
|
||||
|
||||
// Only navigate to GratificationScreen on success
|
||||
store.clearDeepLinkReferrer();
|
||||
navigation.navigate('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
} else {
|
||||
// Registration failed - show error and preserve referrer
|
||||
const errorMessage = result.error || 'Unknown error occurred';
|
||||
console.error('Referral registration failed:', errorMessage);
|
||||
|
||||
// Show error modal with retry option, don't clear referrer
|
||||
showReferralErrorModal(errorMessage);
|
||||
}
|
||||
} else {
|
||||
// Already registered, navigate to gratification
|
||||
store.clearDeepLinkReferrer();
|
||||
navigation.navigate('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
}
|
||||
}, [referrer, registerReferral, navigation]);
|
||||
|
||||
const onEarnPointsPress = useCallback(
|
||||
async (skipReferralFlow = true) => {
|
||||
const hasUserAnIdentityDocumentRegistered_result =
|
||||
await hasUserAnIdentityDocumentRegistered();
|
||||
if (!hasUserAnIdentityDocumentRegistered_result) {
|
||||
showIdentityVerificationModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUserDoneThePointsDisclosure_result =
|
||||
await hasUserDoneThePointsDisclosure();
|
||||
if (!hasUserDoneThePointsDisclosure_result) {
|
||||
showPointsInfoScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// User has completed both checks
|
||||
if (!skipReferralFlow && hasReferrer && isReferralConfirmed === true) {
|
||||
await handleReferralFlow();
|
||||
} else {
|
||||
navigation.navigate('Points');
|
||||
}
|
||||
},
|
||||
[
|
||||
hasReferrer,
|
||||
isReferralConfirmed,
|
||||
navigation,
|
||||
showIdentityVerificationModal,
|
||||
showPointsInfoScreen,
|
||||
handleReferralFlow,
|
||||
],
|
||||
);
|
||||
|
||||
return { onEarnPointsPress };
|
||||
};
|
||||
53
app/src/hooks/usePoints.ts
Normal file
53
app/src/hooks/usePoints.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { getNextSundayNoonUTC, type IncomingPoints } from '@/services/points';
|
||||
import { usePointEventStore } from '@/stores/pointEventStore';
|
||||
|
||||
/*
|
||||
* Hook to get incoming points for the user. It shows the optimistic incoming points.
|
||||
* Refreshes incoming points once on mount.
|
||||
*/
|
||||
export const useIncomingPoints = (): IncomingPoints => {
|
||||
const incomingPoints = usePointEventStore(state => state.incomingPoints);
|
||||
const totalOptimisticIncomingPoints = usePointEventStore(state =>
|
||||
state.totalOptimisticIncomingPoints(),
|
||||
);
|
||||
const refreshIncomingPoints = usePointEventStore(
|
||||
state => state.refreshIncomingPoints,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only refresh once on mount - the store handles promise caching for concurrent calls
|
||||
refreshIncomingPoints();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty deps: only run once on mount
|
||||
|
||||
return {
|
||||
amount: totalOptimisticIncomingPoints,
|
||||
expectedDate: incomingPoints.expectedDate,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* Hook to fetch total points for the user. It refetches the total points when the next points update time is reached (each Sunday noon UTC).
|
||||
*/
|
||||
export const usePoints = () => {
|
||||
const points = usePointEventStore(state => state.points);
|
||||
const nextPointsUpdate = getNextSundayNoonUTC().getTime();
|
||||
const refreshPoints = usePointEventStore(state => state.refreshPoints);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPoints();
|
||||
// refresh when points update time changes as its the only time points can change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nextPointsUpdate]);
|
||||
|
||||
return {
|
||||
amount: points,
|
||||
refetch: refreshPoints,
|
||||
};
|
||||
};
|
||||
51
app/src/hooks/usePointsGuardrail.ts
Normal file
51
app/src/hooks/usePointsGuardrail.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
} from '@/services/points';
|
||||
|
||||
/**
|
||||
* Guard hook that validates points screen access requirements.
|
||||
* Redirects to Home if user hasn't:
|
||||
* 1. Registered an identity document
|
||||
* 2. Completed the points disclosure
|
||||
*
|
||||
* This prevents users from accessing the Points screen through:
|
||||
* - GratificationScreen's "Explore rewards" button
|
||||
* - CloudBackupSettings return paths
|
||||
* - Any other navigation bypass
|
||||
*/
|
||||
export const usePointsGuardrail = () => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let isActive = true;
|
||||
|
||||
const checkRequirements = async () => {
|
||||
const hasDocument = await hasUserAnIdentityDocumentRegistered();
|
||||
const hasDisclosed = await hasUserDoneThePointsDisclosure();
|
||||
|
||||
// Only navigate if the screen is still focused
|
||||
if (isActive && (!hasDocument || !hasDisclosed)) {
|
||||
// User hasn't met requirements, redirect to Home
|
||||
navigation.navigate('Home', {});
|
||||
}
|
||||
};
|
||||
checkRequirements();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [navigation]),
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,8 @@ const TEST_REFERRER = '0x1234567890123456789012345678901234567890';
|
||||
* Hook for testing referral flow in DEV mode.
|
||||
* Provides automatic timeout trigger (3 seconds) and manual trigger function.
|
||||
*
|
||||
* Flow: Sets referrer → shows confirmation modal → on confirm → registers referral
|
||||
* Flow: Sets referrer → shows confirmation modal → on confirm, checks prerequisites
|
||||
* → if identity doc & points disclosure done → registers referral → navigates to Gratification
|
||||
*
|
||||
* @param shouldAutoTrigger - Whether to automatically trigger the flow after 3 seconds (default: false)
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
|
||||
import { SystemBars } from '@/components/SystemBars';
|
||||
import DeferredLinkingInfoScreen from '@/screens/app/DeferredLinkingInfoScreen';
|
||||
import GratificationScreen from '@/screens/app/GratificationScreen';
|
||||
import LoadingScreen from '@/screens/app/LoadingScreen';
|
||||
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
|
||||
import ModalScreen from '@/screens/app/ModalScreen';
|
||||
@@ -49,6 +50,16 @@ const appScreens = {
|
||||
header: () => <SystemBars style="light" />,
|
||||
},
|
||||
},
|
||||
Gratification: {
|
||||
screen: GratificationScreen,
|
||||
options: {
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#000000' },
|
||||
} as NativeStackNavigationOptions,
|
||||
params: {} as {
|
||||
points?: number;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default appScreens;
|
||||
|
||||
@@ -7,8 +7,11 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac
|
||||
import { black } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { HomeNavBar } from '@/components/navbar';
|
||||
import PointsScreen from '@/components/navbar/Points';
|
||||
import { PointsNavBar } from '@/components/navbar/PointsNavBar';
|
||||
import ReferralScreen from '@/screens/app/ReferralScreen';
|
||||
import HomeScreen from '@/screens/home/HomeScreen';
|
||||
import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
|
||||
import ProofHistoryDetailScreen from '@/screens/home/ProofHistoryDetailScreen';
|
||||
import ProofHistoryScreen from '@/screens/home/ProofHistoryScreen';
|
||||
|
||||
@@ -21,6 +24,14 @@ const homeScreens = {
|
||||
presentation: 'card',
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
Points: {
|
||||
screen: PointsScreen,
|
||||
options: {
|
||||
title: 'Self Points',
|
||||
header: PointsNavBar,
|
||||
presentation: 'card',
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
Referral: {
|
||||
screen: ReferralScreen,
|
||||
options: {
|
||||
@@ -42,6 +53,14 @@ const homeScreens = {
|
||||
headerTintColor: black,
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
PointsInfo: {
|
||||
screen: PointsInfoScreen,
|
||||
options: {
|
||||
headerBackTitle: 'close',
|
||||
title: 'Self Points',
|
||||
animation: 'slide_from_bottom',
|
||||
} as NativeStackNavigationOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export default homeScreens;
|
||||
|
||||
@@ -40,6 +40,7 @@ export type AccountRoutesParamList = {
|
||||
CloudBackupSettings:
|
||||
| {
|
||||
nextScreen?: 'SaveRecoveryPhrase';
|
||||
returnToScreen?: 'Points';
|
||||
}
|
||||
| undefined;
|
||||
ProofSettings: undefined;
|
||||
@@ -57,6 +58,9 @@ export type AppRoutesParamList = {
|
||||
curveOrExponent?: string;
|
||||
};
|
||||
Modal: ModalNavigationParams;
|
||||
Gratification: {
|
||||
points?: number;
|
||||
};
|
||||
StarfallPushCode: undefined;
|
||||
};
|
||||
|
||||
@@ -127,6 +131,13 @@ export type HomeRoutesParamList = {
|
||||
Home: {
|
||||
testReferralFlow?: boolean;
|
||||
};
|
||||
Points: undefined;
|
||||
PointsInfo:
|
||||
| {
|
||||
showNextButton?: boolean;
|
||||
callbackId?: number;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ type NextScreen = keyof Pick<RootStackParamList, 'SaveRecoveryPhrase'>;
|
||||
type CloudBackupScreenProps = StaticScreenProps<
|
||||
| {
|
||||
nextScreen?: NextScreen;
|
||||
returnToScreen?: 'Points';
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
@@ -174,6 +175,10 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
await upload(storedMnemonic.data);
|
||||
toggleCloudBackupEnabled();
|
||||
trackEvent(BackupEvents.CLOUD_BACKUP_ENABLED_DONE);
|
||||
|
||||
if (params?.returnToScreen) {
|
||||
navigation.navigate(params.returnToScreen);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('iCloud backup error', error);
|
||||
} finally {
|
||||
@@ -186,6 +191,8 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
upload,
|
||||
toggleCloudBackupEnabled,
|
||||
trackEvent,
|
||||
navigation,
|
||||
params,
|
||||
selfClient,
|
||||
showNoRegisteredAccountModal,
|
||||
]);
|
||||
@@ -219,6 +226,9 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
// await backupAccount(mnemonics.data.phrase);
|
||||
// setTurnkeyPending(false);
|
||||
|
||||
// if (params?.returnToScreen) {
|
||||
// navigation.navigate(params.returnToScreen);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// if (error instanceof Error && error.message === 'already_exists') {
|
||||
// console.log('Already signed in with Turnkey');
|
||||
@@ -228,7 +238,9 @@ const CloudBackupScreen: React.FC<CloudBackupScreenProps> = ({
|
||||
// error.message === 'already_backed_up'
|
||||
// ) {
|
||||
// console.log('Already backed up with Turnkey');
|
||||
// if (params?.nextScreen) {
|
||||
// if (params?.returnToScreen) {
|
||||
// navigation.navigate(params.returnToScreen);
|
||||
// } else if (params?.nextScreen) {
|
||||
// navigation.navigate(params.nextScreen);
|
||||
// } else {
|
||||
// showAlreadyBackedUpModal();
|
||||
|
||||
269
app/src/screens/app/GratificationScreen.tsx
Normal file
269
app/src/screens/app/GratificationScreen.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text, View, YStack } from 'tamagui';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { X } from '@tamagui/lucide-icons';
|
||||
|
||||
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
|
||||
import youWinAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/youWin.json';
|
||||
import { PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
black,
|
||||
slate700,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import GratificationBg from '@/assets/images/gratification_bg.svg';
|
||||
import SelfLogo from '@/assets/logos/self.svg';
|
||||
import { SystemBars } from '@/components/SystemBars';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
|
||||
const GratificationScreen: React.FC = () => {
|
||||
const { top, bottom } = useSafeAreaInsets();
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute();
|
||||
const params = route.params as { points?: number } | undefined;
|
||||
const pointsEarned = params?.points ?? 0;
|
||||
const [isAnimationFinished, setIsAnimationFinished] = useState(false);
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
const handleExploreRewards = () => {
|
||||
// Navigate to Points screen
|
||||
navigation.navigate('Points');
|
||||
};
|
||||
|
||||
const handleInviteFriend = () => {
|
||||
navigation.navigate('Referral');
|
||||
};
|
||||
|
||||
const handleBackPress = () => {
|
||||
navigation.navigate('Points');
|
||||
};
|
||||
|
||||
const handleAnimationFinish = useCallback(() => {
|
||||
setIsAnimationFinished(true);
|
||||
}, []);
|
||||
|
||||
// Show animation first, then content after it finishes
|
||||
if (!isAnimationFinished) {
|
||||
return (
|
||||
<YStack
|
||||
flex={1}
|
||||
backgroundColor={black}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<DelayedLottieView
|
||||
autoPlay
|
||||
loop={false}
|
||||
source={youWinAnimation}
|
||||
style={styles.animation}
|
||||
onAnimationFinish={handleAnimationFinish}
|
||||
resizeMode="contain"
|
||||
cacheComposition={true}
|
||||
renderMode="HARDWARE"
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack flex={1} backgroundColor={black}>
|
||||
<SystemBars style="light" />
|
||||
{/* Full screen background */}
|
||||
<View
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
zIndex={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<GratificationBg
|
||||
width={screenWidth * 1.1}
|
||||
height={screenHeight * 1.1}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Black overlay for top safe area (status bar) */}
|
||||
<View
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height={top}
|
||||
backgroundColor={black}
|
||||
zIndex={1}
|
||||
/>
|
||||
|
||||
{/* Black overlay for bottom safe area */}
|
||||
<View
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height={bottom}
|
||||
backgroundColor={black}
|
||||
zIndex={1}
|
||||
/>
|
||||
|
||||
{/* Back button */}
|
||||
<View position="absolute" top={top + 20} left={20} zIndex={10}>
|
||||
<Pressable onPress={handleBackPress}>
|
||||
<View
|
||||
backgroundColor={white}
|
||||
width={46}
|
||||
height={46}
|
||||
borderRadius={23}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<X width={24} height={24} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Main content container */}
|
||||
<YStack
|
||||
flex={1}
|
||||
paddingTop={top + 54}
|
||||
paddingBottom={bottom + 50}
|
||||
paddingHorizontal={20}
|
||||
zIndex={2}
|
||||
>
|
||||
{/* Dialogue container */}
|
||||
<YStack
|
||||
flex={1}
|
||||
borderRadius={14}
|
||||
borderTopLeftRadius={14}
|
||||
borderTopRightRadius={14}
|
||||
paddingTop={84}
|
||||
paddingBottom={24}
|
||||
paddingHorizontal={24}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{/* Logo icon */}
|
||||
<View marginBottom={12} style={styles.logoContainer}>
|
||||
<SelfLogo width={37} height={37} />
|
||||
</View>
|
||||
|
||||
{/* Points display */}
|
||||
<YStack alignItems="center" gap={0} marginBottom={18}>
|
||||
<Text
|
||||
fontFamily={dinotBold}
|
||||
fontSize={98}
|
||||
color={white}
|
||||
textAlign="center"
|
||||
letterSpacing={-2}
|
||||
lineHeight={98}
|
||||
>
|
||||
{pointsEarned}
|
||||
</Text>
|
||||
<Text
|
||||
fontFamily={dinot}
|
||||
fontSize={48}
|
||||
fontWeight="900"
|
||||
color={white}
|
||||
textAlign="center"
|
||||
letterSpacing={-2}
|
||||
lineHeight={48}
|
||||
>
|
||||
points earned
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* Description text */}
|
||||
<Text
|
||||
fontFamily={dinot}
|
||||
fontSize={18}
|
||||
fontWeight="500"
|
||||
color={white}
|
||||
textAlign="center"
|
||||
lineHeight={24}
|
||||
marginBottom={20}
|
||||
paddingHorizontal={0}
|
||||
>
|
||||
Earn more points by proving your identity and referring friends
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{/* Bottom button container */}
|
||||
<YStack
|
||||
paddingTop={20}
|
||||
paddingBottom={20}
|
||||
paddingHorizontal={20}
|
||||
gap={12}
|
||||
>
|
||||
<PrimaryButton
|
||||
onPress={handleExploreRewards}
|
||||
style={styles.primaryButton}
|
||||
>
|
||||
Explore rewards
|
||||
</PrimaryButton>
|
||||
<Pressable
|
||||
onPress={handleInviteFriend}
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryButton,
|
||||
pressed && styles.secondaryButtonPressed,
|
||||
]}
|
||||
>
|
||||
<RNText style={styles.secondaryButtonText}>Invite friends</RNText>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GratificationScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
primaryButton: {
|
||||
borderRadius: 60,
|
||||
borderWidth: 1,
|
||||
borderColor: slate700,
|
||||
padding: 14,
|
||||
},
|
||||
secondaryButton: {
|
||||
width: '100%',
|
||||
backgroundColor: white,
|
||||
borderWidth: 1,
|
||||
borderColor: white,
|
||||
padding: 14,
|
||||
borderRadius: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
secondaryButtonPressed: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
color: black,
|
||||
textAlign: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
animation: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
@@ -76,8 +76,8 @@ const ReferralScreen: React.FC = () => {
|
||||
gap={42}
|
||||
>
|
||||
<ReferralInfo
|
||||
title="Invite friends to Self"
|
||||
description="When friends install Self and use your referral link you'll both get rewarded."
|
||||
title="Invite friends and earn points"
|
||||
description="When friends install Self and use your referral link you'll both receive exclusive points."
|
||||
learnMoreText="Learn more"
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import { ScrollView, Spinner, YStack } from 'tamagui';
|
||||
import {
|
||||
Button,
|
||||
ScrollView,
|
||||
Spinner,
|
||||
Text,
|
||||
View,
|
||||
XStack,
|
||||
YStack,
|
||||
} from 'tamagui';
|
||||
import {
|
||||
useFocusEffect,
|
||||
useIsFocused,
|
||||
@@ -17,10 +25,20 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import type { DocumentMetadata } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { DocumentEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import { black, slate50 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import {
|
||||
DocumentEvents,
|
||||
PointEvents,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import {
|
||||
black,
|
||||
blue600,
|
||||
slate50,
|
||||
slate300,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
|
||||
import LogoInversed from '@/assets/images/logo_inversed.svg';
|
||||
import EmptyIdCard from '@/components/homescreen/EmptyIdCard';
|
||||
import ExpiredIdCard from '@/components/homescreen/ExpiredIdCard';
|
||||
import IdCardLayout from '@/components/homescreen/IdCard';
|
||||
@@ -28,8 +46,9 @@ import PendingIdCard from '@/components/homescreen/PendingIdCard';
|
||||
import UnregisteredIdCard from '@/components/homescreen/UnregisteredIdCard';
|
||||
import { useAppUpdates } from '@/hooks/useAppUpdates';
|
||||
import useConnectionModal from '@/hooks/useConnectionModal';
|
||||
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
|
||||
import { usePoints } from '@/hooks/usePoints';
|
||||
import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
@@ -77,6 +96,8 @@ const HomeScreen: React.FC = () => {
|
||||
v => v.status === 'pending' || v.status === 'processing',
|
||||
);
|
||||
|
||||
const { amount: selfPoints } = usePoints();
|
||||
|
||||
// DEV MODE: Test referral flow hook (only show alert when screen is focused)
|
||||
const isFocused = useIsFocused();
|
||||
const route = useRoute();
|
||||
@@ -170,28 +191,28 @@ const HomeScreen: React.FC = () => {
|
||||
// Calculate bottom padding to prevent button bleeding into system navigation
|
||||
const bottomPadding = useSafeBottomPadding(20);
|
||||
|
||||
const { registerReferral } = useRegisterReferral();
|
||||
// Create a stable reference to avoid hook dependency issues
|
||||
const onEarnPointsPressRef = useRef<
|
||||
((skipReferralFlow?: boolean) => Promise<void>) | null
|
||||
>(null);
|
||||
|
||||
const handleReferralConfirmed = useCallback(async () => {
|
||||
if (!referrer) {
|
||||
return;
|
||||
}
|
||||
const store = useUserStore.getState();
|
||||
if (!store.isReferrerRegistered(referrer)) {
|
||||
const result = await registerReferral(referrer);
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
store.markReferrerAsRegistered(referrer);
|
||||
}
|
||||
store.clearDeepLinkReferrer();
|
||||
}, [referrer, registerReferral]);
|
||||
|
||||
useReferralConfirmation({
|
||||
const { isReferralConfirmed } = useReferralConfirmation({
|
||||
hasReferrer,
|
||||
onConfirmed: handleReferralConfirmed,
|
||||
onConfirmed: () => {
|
||||
onEarnPointsPressRef.current?.(false);
|
||||
},
|
||||
});
|
||||
|
||||
const { onEarnPointsPress } = useEarnPointsFlow({
|
||||
hasReferrer,
|
||||
isReferralConfirmed,
|
||||
});
|
||||
|
||||
// Update the ref whenever onEarnPointsPress changes
|
||||
useEffect(() => {
|
||||
onEarnPointsPressRef.current = onEarnPointsPress;
|
||||
}, [onEarnPointsPress]);
|
||||
|
||||
const handleDocumentPress = useCallback(
|
||||
(metadata: DocumentMetadata, documentData: IDDocument) => {
|
||||
selfClient.trackEvent(DocumentEvents.DOCUMENT_SELECTED, {
|
||||
@@ -323,6 +344,86 @@ const HomeScreen: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<YStack
|
||||
elevation={8}
|
||||
backgroundColor="white"
|
||||
width="100%"
|
||||
paddingTop={20}
|
||||
paddingHorizontal={20}
|
||||
paddingBottom={bottomPadding}
|
||||
borderTopLeftRadius={18}
|
||||
borderTopRightRadius={18}
|
||||
style={{
|
||||
// Matches: box-shadow: 0 -6px 14px 0 rgba(0, 0, 0, 0.05);
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 14,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<XStack marginBottom={32} gap={22}>
|
||||
<View
|
||||
width={68}
|
||||
height={68}
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={slate300}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<LogoInversed width={33} height={33} />
|
||||
</View>
|
||||
<YStack gap={4}>
|
||||
<Text
|
||||
color={black}
|
||||
fontFamily={dinot}
|
||||
fontSize={20}
|
||||
fontStyle="normal"
|
||||
fontWeight="500"
|
||||
lineHeight={22}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{`${selfPoints} SELF POINTS`}
|
||||
</Text>
|
||||
<Text
|
||||
color={black}
|
||||
width="60%"
|
||||
fontFamily={dinot}
|
||||
fontSize={16}
|
||||
fontStyle="normal"
|
||||
fontWeight="500"
|
||||
lineHeight={22}
|
||||
>
|
||||
Earn points by referring friends, disclosing proof requests, and
|
||||
more.
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Button
|
||||
backgroundColor="white"
|
||||
paddingHorizontal={22}
|
||||
paddingVertical={24}
|
||||
borderRadius={5}
|
||||
borderWidth={1}
|
||||
borderColor={slate300}
|
||||
testID="earn-points-button"
|
||||
onPress={() => {
|
||||
selfClient.trackEvent(PointEvents.HOME_POINT_EARN_POINTS_OPENED);
|
||||
|
||||
onEarnPointsPress(true);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
color={blue600}
|
||||
textAlign="center"
|
||||
fontFamily={dinot}
|
||||
fontSize={18}
|
||||
height={22}
|
||||
>
|
||||
Earn points
|
||||
</Text>
|
||||
</Button>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
226
app/src/screens/home/PointsInfoScreen.tsx
Normal file
226
app/src/screens/home/PointsInfoScreen.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Image, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { ScrollView, Text, View, XStack, YStack } from 'tamagui';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
|
||||
import { PrimaryButton, Title } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
black,
|
||||
slate50,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import CheckmarkSquareIcon from '@/assets/icons/checkmark_square.svg';
|
||||
import CloudBackupIcon from '@/assets/icons/cloud_backup.svg';
|
||||
import PushNotificationsIcon from '@/assets/icons/push_notifications.svg';
|
||||
import StarIcon from '@/assets/icons/star.svg';
|
||||
import Referral from '@/assets/images/referral.png';
|
||||
import {
|
||||
getModalCallbacks,
|
||||
unregisterModalCallbacks,
|
||||
} from '@/utils/modalCallbackRegistry';
|
||||
|
||||
type PointsInfoScreenProps = StaticScreenProps<
|
||||
| {
|
||||
showNextButton?: boolean;
|
||||
callbackId?: number;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
||||
interface EarnPointsItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const EarnPointsItem = ({ title, description, icon }: EarnPointsItemProps) => {
|
||||
return (
|
||||
<XStack
|
||||
padding={10}
|
||||
backgroundColor={slate50}
|
||||
borderRadius={10}
|
||||
gap={20}
|
||||
alignItems="center"
|
||||
>
|
||||
<View
|
||||
style={styles.iconContainer}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
<YStack gap={4} flex={1}>
|
||||
<Text style={styles.pointsItemTitle}>{title}</Text>
|
||||
<Text style={styles.pointsItemDescription}>{description}</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
);
|
||||
};
|
||||
|
||||
const EARN_POINTS_ITEMS = [
|
||||
{
|
||||
title: 'Inviting friends to Self',
|
||||
description:
|
||||
"You'll both receive Self Points after your friend signs their first proof.",
|
||||
icon: <StarIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Signing proof requests',
|
||||
description:
|
||||
'Every successful proof that you sign will reward you with Self Points.',
|
||||
icon: <CheckmarkSquareIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Enabling push notifications',
|
||||
description: 'Instantly earn Self Points by activating push notifications.',
|
||||
icon: <PushNotificationsIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
{
|
||||
title: 'Activate cloud back up',
|
||||
description:
|
||||
'Securely back up your account in settings to earn Self Points instantly.',
|
||||
icon: <CloudBackupIcon width={40} height={40} color={black} />,
|
||||
},
|
||||
];
|
||||
|
||||
const PointsInfoScreen: React.FC<PointsInfoScreenProps> = ({
|
||||
route: { params },
|
||||
}) => {
|
||||
const { showNextButton, callbackId } = params || {};
|
||||
const { left, right, bottom } = useSafeAreaInsets();
|
||||
const callbacks = useMemo(
|
||||
() => (callbackId ? getModalCallbacks(callbackId) : undefined),
|
||||
[callbackId],
|
||||
);
|
||||
const buttonPressedRef = useRef(false);
|
||||
|
||||
// Handle button press: mark as pressed and call the callback
|
||||
const handleNextPress = useCallback(() => {
|
||||
if (callbackId !== undefined) {
|
||||
buttonPressedRef.current = true;
|
||||
}
|
||||
callbacks?.onButtonPress();
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
// Cleanup: Call onModalDismiss and unregister callbacks when component unmounts
|
||||
// Only call onModalDismiss if user navigated back (didn't press the button)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (callbackId !== undefined) {
|
||||
// Always unregister on unmount to prevent memory leaks
|
||||
if (!buttonPressedRef.current) {
|
||||
// User navigated back without pressing "Next" - call onModalDismiss to clear referrer
|
||||
callbacks?.onModalDismiss();
|
||||
}
|
||||
unregisterModalCallbacks(callbackId);
|
||||
}
|
||||
};
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} gap={40} paddingBottom={bottom} backgroundColor={white}>
|
||||
<Image
|
||||
source={Referral}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 300,
|
||||
resizeMode: 'cover',
|
||||
}}
|
||||
/>
|
||||
<ScrollView paddingLeft={20 + left} paddingRight={20 + right}>
|
||||
<YStack gap={20}>
|
||||
<YStack gap={2}>
|
||||
<Title>How it works</Title>
|
||||
<Text style={styles.description}>
|
||||
Self Points are rewards you earn for engaging with the Self
|
||||
platform. You can earn Points by:
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack gap={10}>
|
||||
{EARN_POINTS_ITEMS.map(item => (
|
||||
<EarnPointsItem key={item.title} {...item} />
|
||||
))}
|
||||
</YStack>
|
||||
<YStack gap={2}>
|
||||
<Title>Points are deposited at noon UTC every Sunday</Title>
|
||||
<Text style={styles.description}>
|
||||
To ensure privacy and security on-chain, points are deposited into
|
||||
your wallet every Sunday at noon UTC.
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack style={styles.instructionsContainer} gap={12}>
|
||||
<Text style={styles.instructionsText}>
|
||||
Any points that you earn during the week will be added to your
|
||||
account on the following Sunday.
|
||||
</Text>
|
||||
<Text style={styles.instructionsText}>
|
||||
You can track your incoming points in the Self app along with the
|
||||
countdown to Self Sunday every week.
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
{showNextButton && (
|
||||
<View paddingTop={20} paddingLeft={20 + left} paddingRight={20 + right}>
|
||||
<PrimaryButton onPress={handleNextPress}>Next</PrimaryButton>
|
||||
</View>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointsInfoScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
description: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: black,
|
||||
},
|
||||
instructionsContainer: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
backgroundColor: slate50,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 10,
|
||||
},
|
||||
instructionsText: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
},
|
||||
nextButton: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
pointsItemTitle: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
color: black,
|
||||
},
|
||||
pointsItemDescription: {
|
||||
fontFamily: dinot,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: slate500,
|
||||
},
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import type { LottieViewProps } from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Linking, StyleSheet, View } from 'react-native';
|
||||
import { ScrollView, Spinner } from 'tamagui';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useIsFocused, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { DelayedLottieView, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
notificationSuccess,
|
||||
} from '@/integrations/haptics';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
|
||||
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
|
||||
import { ProofStatus } from '@/stores/proofTypes';
|
||||
|
||||
@@ -39,6 +42,8 @@ const SuccessScreen: React.FC = () => {
|
||||
const selfApp = useSelfAppStore(state => state.selfApp);
|
||||
const appName = selfApp?.appName;
|
||||
const goHome = useHapticNavigation('Home');
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
const { updateProofStatus } = useProofHistoryStore();
|
||||
|
||||
@@ -53,18 +58,41 @@ const SuccessScreen: React.FC = () => {
|
||||
useState<LottieViewProps['source']>(loadingAnimation);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
const [countdownStarted, setCountdownStarted] = useState(false);
|
||||
const [whitelistedPoints, setWhitelistedPoints] = useState<
|
||||
number | null | undefined
|
||||
>(undefined);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const onOkPress = useCallback(async () => {
|
||||
if (whitelistedPoints === undefined) return;
|
||||
buttonTap();
|
||||
goHome();
|
||||
const completedSessionId = sessionId;
|
||||
setTimeout(() => {
|
||||
if (useProvingStore.getState().uuid === completedSessionId) {
|
||||
selfClient.getSelfAppState().cleanSelfApp();
|
||||
}
|
||||
}, 2000);
|
||||
}, [goHome, selfClient, sessionId, useProvingStore]);
|
||||
|
||||
if (whitelistedPoints !== null) {
|
||||
navigation.navigate('Gratification', {
|
||||
points: whitelistedPoints,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (useProvingStore.getState().uuid === completedSessionId) {
|
||||
selfClient.getSelfAppState().cleanSelfApp();
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
goHome();
|
||||
setTimeout(() => {
|
||||
if (useProvingStore.getState().uuid === completedSessionId) {
|
||||
selfClient.getSelfAppState().cleanSelfApp();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}, [
|
||||
whitelistedPoints,
|
||||
navigation,
|
||||
goHome,
|
||||
selfClient,
|
||||
sessionId,
|
||||
useProvingStore,
|
||||
]);
|
||||
|
||||
function cancelDeeplinkCallbackRedirect() {
|
||||
setCountdown(null);
|
||||
@@ -79,8 +107,33 @@ const SuccessScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
if (currentState !== 'completed') return;
|
||||
|
||||
if (!selfApp?.endpoint) {
|
||||
setWhitelistedPoints(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkWhitelist = async () => {
|
||||
try {
|
||||
const whitelistedContracts = await getWhiteListedDisclosureAddresses();
|
||||
const endpoint = selfApp.endpoint.toLowerCase();
|
||||
const whitelistedContract = whitelistedContracts.find(
|
||||
c => c.contract_address.toLowerCase() === endpoint,
|
||||
);
|
||||
setWhitelistedPoints(
|
||||
whitelistedContract?.points_per_disclosure ?? null,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error checking whitelist:', error);
|
||||
setWhitelistedPoints(null);
|
||||
}
|
||||
};
|
||||
|
||||
checkWhitelist();
|
||||
}, [currentState, selfApp?.endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentState === 'completed') {
|
||||
notificationSuccess();
|
||||
setAnimationSource(succesAnimation);
|
||||
@@ -91,18 +144,16 @@ const SuccessScreen: React.FC = () => {
|
||||
});
|
||||
|
||||
if (isFocused && !countdownStarted && selfApp?.deeplinkCallback) {
|
||||
if (selfApp?.deeplinkCallback) {
|
||||
try {
|
||||
const url = new URL(selfApp.deeplinkCallback);
|
||||
if (url) {
|
||||
setCountdown(5);
|
||||
setCountdownStarted(true);
|
||||
}
|
||||
} catch {
|
||||
console.warn(
|
||||
'Invalid deep link URL provided (URL sanitized for security)',
|
||||
);
|
||||
try {
|
||||
const url = new URL(selfApp.deeplinkCallback);
|
||||
if (url) {
|
||||
setCountdown(5);
|
||||
setCountdownStarted(true);
|
||||
}
|
||||
} catch {
|
||||
console.warn(
|
||||
'Invalid deep link URL provided (URL sanitized for security)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (currentState === 'failure' || currentState === 'error') {
|
||||
@@ -206,9 +257,12 @@ const SuccessScreen: React.FC = () => {
|
||||
<PrimaryButton
|
||||
trackEvent={ProofEvents.PROOF_RESULT_ACKNOWLEDGED}
|
||||
disabled={
|
||||
currentState !== 'completed' &&
|
||||
currentState !== 'error' &&
|
||||
currentState !== 'failure'
|
||||
(currentState !== 'completed' &&
|
||||
currentState !== 'error' &&
|
||||
currentState !== 'failure') ||
|
||||
(currentState === 'completed' &&
|
||||
whitelistedPoints === undefined &&
|
||||
!(countdown !== null && countdown > 0))
|
||||
}
|
||||
onPress={
|
||||
countdown !== null && countdown > 0
|
||||
@@ -222,6 +276,8 @@ const SuccessScreen: React.FC = () => {
|
||||
<Spinner />
|
||||
) : countdown !== null && countdown > 0 ? (
|
||||
'Cancel'
|
||||
) : whitelistedPoints === undefined ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
'OK'
|
||||
)}
|
||||
|
||||
775
app/tests/src/hooks/useEarnPointsFlow.test.ts
Normal file
775
app/tests/src/hooks/useEarnPointsFlow.test.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
|
||||
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
|
||||
import {
|
||||
hasUserAnIdentityDocumentRegistered,
|
||||
hasUserDoneThePointsDisclosure,
|
||||
POINT_VALUES,
|
||||
pointsSelfApp,
|
||||
} from '@/services/points';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
useSelfClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/hooks/useRegisterReferral', () => ({
|
||||
useRegisterReferral: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/services/points', () => ({
|
||||
hasUserAnIdentityDocumentRegistered: jest.fn(),
|
||||
hasUserDoneThePointsDisclosure: jest.fn(),
|
||||
pointsSelfApp: jest.fn(),
|
||||
POINT_VALUES: {
|
||||
referee: 24,
|
||||
},
|
||||
}));
|
||||
|
||||
// userStore is used as-is, no mock needed
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
const mockUseSelfClient = useSelfClient as jest.MockedFunction<
|
||||
typeof useSelfClient
|
||||
>;
|
||||
const mockUseRegisterReferral = useRegisterReferral as jest.MockedFunction<
|
||||
typeof useRegisterReferral
|
||||
>;
|
||||
const mockHasUserAnIdentityDocumentRegistered =
|
||||
hasUserAnIdentityDocumentRegistered as jest.MockedFunction<
|
||||
typeof hasUserAnIdentityDocumentRegistered
|
||||
>;
|
||||
const mockHasUserDoneThePointsDisclosure =
|
||||
hasUserDoneThePointsDisclosure as jest.MockedFunction<
|
||||
typeof hasUserDoneThePointsDisclosure
|
||||
>;
|
||||
const mockPointsSelfApp = pointsSelfApp as jest.MockedFunction<
|
||||
typeof pointsSelfApp
|
||||
>;
|
||||
|
||||
describe('useEarnPointsFlow', () => {
|
||||
const mockSetSelfApp = jest.fn();
|
||||
const mockSelfClient = {
|
||||
getSelfAppState: jest.fn(() => ({
|
||||
setSelfApp: mockSetSelfApp,
|
||||
})),
|
||||
};
|
||||
const mockRegisterReferral = jest.fn();
|
||||
const mockSelfApp = {
|
||||
appName: '✨ Self Points',
|
||||
endpoint: '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0',
|
||||
sessionId: 'test-session-id',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSetSelfApp.mockClear();
|
||||
jest.useFakeTimers();
|
||||
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
} as any);
|
||||
|
||||
mockUseSelfClient.mockReturnValue(mockSelfClient as any);
|
||||
|
||||
mockUseRegisterReferral.mockReturnValue({
|
||||
registerReferral: mockRegisterReferral,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Reset user store state
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
useUserStore.getState().registeredReferrers.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Identity verification flow', () => {
|
||||
it('should show identity verification modal when user has no identity document', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockHasUserAnIdentityDocumentRegistered).toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Identity Verification Required',
|
||||
bodyText:
|
||||
'To access Self Points, you need to register an identity document with Self first. This helps us verify your identity and keep your points secure.',
|
||||
buttonText: 'Verify Identity',
|
||||
secondaryButtonText: 'Not Now',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to CountryPicker when identity verification modal button is pressed', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
expect(callbacks).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('CountryPicker');
|
||||
});
|
||||
|
||||
it('should clear referrer when identity verification modal is dismissed with referrer', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Points disclosure flow', () => {
|
||||
it('should show points disclosure modal when user has not done disclosure', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockHasUserAnIdentityDocumentRegistered).toHaveBeenCalled();
|
||||
expect(mockHasUserDoneThePointsDisclosure).toHaveBeenCalled();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// We pass callbackId to retrieve and invoke the callback that displays the points disclosure modal
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Points Disclosure Required',
|
||||
bodyText:
|
||||
'To access Self Points, you need to complete the points disclosure first. This helps us verify your identity and keep your points secure.',
|
||||
buttonText: 'Complete Points Disclosure',
|
||||
secondaryButtonText: 'Not Now',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to Prove screen when points disclosure modal button is pressed', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
mockPointsSelfApp.mockResolvedValue(mockSelfApp as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
expect(callbacks).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockPointsSelfApp).toHaveBeenCalled();
|
||||
|
||||
// setSelfApp is called synchronously after pointsSelfApp resolves
|
||||
expect(mockSetSelfApp).toHaveBeenCalledWith(mockSelfApp);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter');
|
||||
});
|
||||
|
||||
it('should clear referrer when points disclosure modal is dismissed with referrer', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct navigation flow', () => {
|
||||
it('should navigate to Points screen when user has completed all checks and no referrer', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Points');
|
||||
});
|
||||
|
||||
it('should navigate to Points when user has completed all checks, has referrer, but skipReferralFlow is true', async () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(true);
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Points');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Referral flow', () => {
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
|
||||
beforeEach(() => {
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('should handle referral flow when referrer is confirmed and not skipped', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
expect(useUserStore.getState().isReferrerRegistered(referrer)).toBe(true);
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not register referral if already registered', async () => {
|
||||
useUserStore.getState().markReferrerAsRegistered(referrer);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error modal and preserve referrer if referral registration fails', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Network error occurred',
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
|
||||
// Should NOT navigate to Gratification on failure
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
|
||||
// Should show error modal instead
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Referral Registration Failed',
|
||||
bodyText: expect.stringContaining('Network error occurred'),
|
||||
buttonText: 'Try Again',
|
||||
secondaryButtonText: 'Dismiss',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// Should preserve the referrer for retry
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBe(referrer);
|
||||
|
||||
// Should log the error
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Referral registration failed:',
|
||||
'Network error occurred',
|
||||
);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should retry referral registration when error modal retry button is pressed', async () => {
|
||||
// First call fails, second call succeeds
|
||||
mockRegisterReferral
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Network error',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// First attempt - should fail
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Modal', {
|
||||
titleText: 'Referral Registration Failed',
|
||||
bodyText: expect.stringContaining('Network error'),
|
||||
buttonText: 'Try Again',
|
||||
secondaryButtonText: 'Dismiss',
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
// Referrer should still be in store
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBe(referrer);
|
||||
|
||||
// Get the callback from the error modal and trigger retry
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
mockNavigate.mockClear();
|
||||
|
||||
// Retry - should succeed
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledTimes(2);
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
|
||||
// Should now navigate to Gratification
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: POINT_VALUES.referee,
|
||||
});
|
||||
|
||||
// Should mark referrer as registered and clear it
|
||||
expect(useUserStore.getState().isReferrerRegistered(referrer)).toBe(true);
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should clear referrer when error modal is dismissed', async () => {
|
||||
mockRegisterReferral.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'API error',
|
||||
});
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
// Dismiss the error modal
|
||||
act(() => {
|
||||
callbacks!.onModalDismiss();
|
||||
});
|
||||
|
||||
// Referrer should be cleared to prevent retry loop
|
||||
expect(useUserStore.getState().deepLinkReferrer).toBeUndefined();
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should not handle referral flow when isReferralConfirmed is false', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should not handle referral flow when isReferralConfirmed is undefined', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should not handle referral flow when hasReferrer is false', async () => {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
|
||||
it('should handle referral flow when referrer is not in store but hasReferrer is true', async () => {
|
||||
useUserStore.getState().clearDeepLinkReferrer();
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
// Should not call registerReferral if referrer is not in store
|
||||
expect(mockRegisterReferral).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('Gratification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle errors in hasUserAnIdentityDocumentRegistered gracefully', async () => {
|
||||
// Mock to return false on error (as the actual function catches errors and returns false)
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
// The function catches errors and returns false, so it should show identity verification modal
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'Modal',
|
||||
expect.objectContaining({
|
||||
titleText: 'Identity Verification Required',
|
||||
callbackId: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in hasUserDoneThePointsDisclosure gracefully', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
// Mock to return false on error (as the actual function catches errors and returns false)
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
// The function catches errors and returns false, so it should show points disclosure modal
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'Modal',
|
||||
expect.objectContaining({
|
||||
titleText: 'Points Disclosure Required',
|
||||
callbackId: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call pointsSelfApp when navigating to points proof', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(false);
|
||||
mockPointsSelfApp.mockResolvedValue(mockSelfApp as any);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEarnPointsFlow({
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('PointsInfo', {
|
||||
showNextButton: true,
|
||||
callbackId: expect.any(Number),
|
||||
});
|
||||
|
||||
const pointsInfoCallbackId = mockNavigate.mock.calls[0][1].callbackId;
|
||||
const pointsInfoCallbacks = getModalCallbacks(pointsInfoCallbackId);
|
||||
|
||||
await act(async () => {
|
||||
await pointsInfoCallbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
const callbackId = mockNavigate.mock.calls[1][1].callbackId;
|
||||
const callbacks = getModalCallbacks(callbackId);
|
||||
|
||||
await act(async () => {
|
||||
await callbacks!.onButtonPress();
|
||||
});
|
||||
|
||||
// Verify pointsSelfApp was called
|
||||
expect(mockPointsSelfApp).toHaveBeenCalled();
|
||||
|
||||
// setSelfApp should be called when pointsSelfApp succeeds
|
||||
expect(mockSetSelfApp).toHaveBeenCalledWith(mockSelfApp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Callback dependencies', () => {
|
||||
it('should update callbacks when dependencies change', async () => {
|
||||
mockHasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
|
||||
mockHasUserDoneThePointsDisclosure.mockResolvedValue(true);
|
||||
|
||||
const referrer = '0x1234567890123456789012345678901234567890';
|
||||
useUserStore.getState().setDeepLinkReferrer(referrer);
|
||||
mockRegisterReferral.mockResolvedValue({ success: true });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ hasReferrer, isReferralConfirmed }) =>
|
||||
useEarnPointsFlow({ hasReferrer, isReferralConfirmed }),
|
||||
{
|
||||
initialProps: {
|
||||
hasReferrer: false,
|
||||
isReferralConfirmed: undefined,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress();
|
||||
});
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Points');
|
||||
|
||||
mockNavigate.mockClear();
|
||||
|
||||
rerender({
|
||||
hasReferrer: true,
|
||||
isReferralConfirmed: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onEarnPointsPress(false);
|
||||
});
|
||||
|
||||
expect(mockRegisterReferral).toHaveBeenCalledWith(referrer);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describe('navigation', () => {
|
||||
'DocumentNFCTrouble',
|
||||
'DocumentOnboarding',
|
||||
'DocumentSelectorForProving',
|
||||
'Gratification',
|
||||
'Home',
|
||||
'IDPicker',
|
||||
'IdDetails',
|
||||
@@ -88,6 +89,8 @@ describe('navigation', () => {
|
||||
'ManageDocuments',
|
||||
'MockDataDeepLink',
|
||||
'Modal',
|
||||
'Points',
|
||||
'PointsInfo',
|
||||
'ProofHistory',
|
||||
'ProofHistoryDetail',
|
||||
'ProofRequestStatus',
|
||||
|
||||
160
app/tests/src/screens/GratificationScreen.test.tsx
Normal file
160
app/tests/src/screens/GratificationScreen.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import GratificationScreen from '@/screens/app/GratificationScreen';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const MockView = ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
);
|
||||
const mockDimensions = {
|
||||
get: jest.fn(() => ({ width: 320, height: 640 })),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Dimensions: mockDimensions,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
Pressable: ({ onPress, children }: any) => (
|
||||
<button onClick={onPress} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
Text: MockText,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-edge-to-edge', () => ({
|
||||
SystemBars: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0 })),
|
||||
}));
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
useRoute: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components to avoid theme provider requirement
|
||||
jest.mock('tamagui', () => {
|
||||
const View: any = 'View';
|
||||
const Text: any = 'Text';
|
||||
const createViewComponent = (displayName: string) => {
|
||||
const MockComponent = ({ children, ...props }: any) => (
|
||||
<View {...props} testID={displayName}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
MockComponent.displayName = displayName;
|
||||
return MockComponent;
|
||||
};
|
||||
|
||||
const MockYStack = createViewComponent('YStack');
|
||||
const MockView = createViewComponent('View');
|
||||
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
);
|
||||
MockText.displayName = 'Text';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
YStack: MockYStack,
|
||||
View: MockView,
|
||||
Text: MockText,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
DelayedLottieView: ({ onAnimationFinish }: any) => {
|
||||
// Simulate animation finishing immediately
|
||||
setTimeout(() => {
|
||||
onAnimationFinish?.();
|
||||
}, 0);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
PrimaryButton: ({ children, onPress }: any) => (
|
||||
<button onClick={onPress}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft');
|
||||
jest.mock('@/assets/logos/self.svg', () => 'SelfLogo');
|
||||
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
const mockUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
|
||||
|
||||
describe('GratificationScreen', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const mockGoBack = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
goBack: mockGoBack,
|
||||
} as any);
|
||||
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: {},
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should use default points value when not provided', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: {},
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('0')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom points value when provided', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: { points: 50 },
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('50')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display referral points value (24) when passed', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
params: { points: 24 },
|
||||
} as any);
|
||||
|
||||
const { getByText } = render(<GratificationScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('24')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
339
app/tests/src/screens/home/PointsInfoScreen.test.tsx
Normal file
339
app/tests/src/screens/home/PointsInfoScreen.test.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react-native';
|
||||
|
||||
import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
|
||||
import { unregisterModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const MockView = ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
);
|
||||
const MockImage = ({ ...props }: any) => <mock-image {...props} />;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Image: MockImage,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
Text: MockText,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components
|
||||
jest.mock('tamagui', () => {
|
||||
const View: any = 'View';
|
||||
const Text: any = 'Text';
|
||||
const createViewComponent = (displayName: string) => {
|
||||
const MockComponent = ({ children, ...props }: any) => (
|
||||
<View {...props} testID={displayName}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
MockComponent.displayName = displayName;
|
||||
return MockComponent;
|
||||
};
|
||||
|
||||
const MockYStack = createViewComponent('YStack');
|
||||
const MockXStack = createViewComponent('XStack');
|
||||
const MockView = createViewComponent('View');
|
||||
const MockScrollView = createViewComponent('ScrollView');
|
||||
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
);
|
||||
MockText.displayName = 'Text';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
YStack: MockYStack,
|
||||
XStack: MockXStack,
|
||||
View: MockView,
|
||||
Text: MockText,
|
||||
ScrollView: MockScrollView,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mobile SDK components
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
PrimaryButton: ({ children, onPress, ...props }: any) => (
|
||||
<mock-view {...props} onPress={onPress} testID="primary-button">
|
||||
{children}
|
||||
</mock-view>
|
||||
),
|
||||
Title: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock SVG icons
|
||||
jest.mock('@/assets/icons/checkmark_square.svg', () => 'CheckmarkSquareIcon');
|
||||
jest.mock('@/assets/icons/cloud_backup.svg', () => 'CloudBackupIcon');
|
||||
jest.mock(
|
||||
'@/assets/icons/push_notifications.svg',
|
||||
() => 'PushNotificationsIcon',
|
||||
);
|
||||
jest.mock('@/assets/icons/star.svg', () => 'StarIcon');
|
||||
|
||||
// Mock images
|
||||
jest.mock('@/assets/images/referral.png', () => 'ReferralImage');
|
||||
|
||||
jest.mock('@/utils/modalCallbackRegistry', () => ({
|
||||
getModalCallbacks: jest.fn(),
|
||||
registerModalCallbacks: jest.fn(),
|
||||
unregisterModalCallbacks: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUnregisterModalCallbacks =
|
||||
unregisterModalCallbacks as jest.MockedFunction<
|
||||
typeof unregisterModalCallbacks
|
||||
>;
|
||||
|
||||
// Mock getModalCallbacks at module level
|
||||
const { getModalCallbacks } = jest.requireMock('@/utils/modalCallbackRegistry');
|
||||
|
||||
describe('PointsInfoScreen', () => {
|
||||
const mockOnButtonPress = jest.fn();
|
||||
const mockOnModalDismiss = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup getModalCallbacks to return our mock callbacks
|
||||
getModalCallbacks.mockImplementation((id: number) => {
|
||||
if (id === 1) {
|
||||
return {
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: mockOnModalDismiss,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
expect(() => {
|
||||
render(<PointsInfoScreen route={{ params: undefined }} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not show Next button when showNextButton is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is not rendered
|
||||
const nextButton = queryByTestId('primary-button');
|
||||
expect(nextButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should show Next button when showNextButton is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is rendered
|
||||
const nextButton = getByTestId('primary-button');
|
||||
expect(nextButton).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Callback handling', () => {
|
||||
it('should call onModalDismiss and unregister callbacks when component unmounts without button press', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially, no callbacks should be called
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount the component (simulating user navigating back)
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should be called to clear referrer
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
// onButtonPress should not be called (user didn't press the button)
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onModalDismiss on unmount even when showNextButton is false', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Callbacks should be called even if button is not shown (callbackId is present)
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle missing callbacks gracefully', () => {
|
||||
// Mock getModalCallbacks to return undefined
|
||||
getModalCallbacks.mockReturnValue(undefined);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 999 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting with missing callbacks
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still attempt to unregister
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(999);
|
||||
});
|
||||
|
||||
it('should handle missing callbackId gracefully', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen route={{ params: { showNextButton: true } }} />,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting without callbackId
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should not attempt to unregister if no callbackId
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button press handling', () => {
|
||||
it('should call onButtonPress and unregister callbacks when Next button is pressed, then not call onModalDismiss on unmount', () => {
|
||||
const { getByTestId, unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should NOT be unregistered yet (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
// onModalDismiss should NOT be called (button was pressed)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
|
||||
// Clear mock calls from button press
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Unmount the component
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should NOT be called (button was pressed, not navigated back)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should allow multiple button presses without unregistering callbacks (regression test)', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button first time
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
|
||||
// Press the button again (simulating returning to this screen after modal dismissal)
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called again
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(2);
|
||||
// Callbacks should still NOT be unregistered (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Referrer cleanup integration', () => {
|
||||
it('should ensure cleanup is called in correct order for referrer clearing', () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const onModalDismissWithTracking = jest.fn(() => {
|
||||
callOrder.push('onModalDismiss');
|
||||
});
|
||||
|
||||
const unregisterWithTracking = jest.fn(() => {
|
||||
callOrder.push('unregister');
|
||||
});
|
||||
|
||||
getModalCallbacks.mockReturnValue({
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: onModalDismissWithTracking,
|
||||
});
|
||||
|
||||
mockUnregisterModalCallbacks.mockImplementation(unregisterWithTracking);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Verify onModalDismiss is called before unregister
|
||||
expect(callOrder).toEqual(['onModalDismiss', 'unregister']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useIsFocused, useNavigation } from '@react-navigation/native';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -47,6 +47,7 @@ jest.mock('react-native', () => ({
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useIsFocused: jest.fn(),
|
||||
useNavigation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('tamagui', () => ({
|
||||
@@ -126,6 +127,10 @@ jest.mock('@/layouts/ExpandableBottomLayout', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/services/points/utils', () => ({
|
||||
getWhiteListedDisclosureAddresses: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/proofHistoryStore', () => ({
|
||||
useProofHistoryStore: jest.fn(),
|
||||
}));
|
||||
@@ -147,6 +152,11 @@ const { buttonTap, notificationSuccess } = jest.requireMock(
|
||||
buttonTap: jest.Mock;
|
||||
notificationSuccess: jest.Mock;
|
||||
};
|
||||
const { getWhiteListedDisclosureAddresses } = jest.requireMock(
|
||||
'@/services/points/utils',
|
||||
) as {
|
||||
getWhiteListedDisclosureAddresses: jest.Mock;
|
||||
};
|
||||
const { useProofHistoryStore } = jest.requireMock(
|
||||
'@/stores/proofHistoryStore',
|
||||
) as {
|
||||
@@ -155,6 +165,7 @@ const { useProofHistoryStore } = jest.requireMock(
|
||||
|
||||
describe('ProofRequestStatusScreen', () => {
|
||||
const mockGoHome = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockCleanSelfApp = jest.fn();
|
||||
const mockUpdateProofStatus = jest.fn();
|
||||
@@ -169,6 +180,7 @@ describe('ProofRequestStatusScreen', () => {
|
||||
selfApp: {
|
||||
appName: string;
|
||||
deeplinkCallback: string | null;
|
||||
endpoint?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -186,14 +198,19 @@ describe('ProofRequestStatusScreen', () => {
|
||||
selfApp: {
|
||||
appName: 'Verifier',
|
||||
deeplinkCallback: null,
|
||||
endpoint: null,
|
||||
},
|
||||
};
|
||||
|
||||
(useIsFocused as jest.Mock).mockReturnValue(true);
|
||||
(useNavigation as jest.Mock).mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
});
|
||||
useHapticNavigation.mockReturnValue(mockGoHome);
|
||||
useProofHistoryStore.mockReturnValue({
|
||||
updateProofStatus: mockUpdateProofStatus,
|
||||
});
|
||||
getWhiteListedDisclosureAddresses.mockResolvedValue([]);
|
||||
|
||||
const useProvingStore = Object.assign(
|
||||
(selector: (state: typeof provingState) => unknown) =>
|
||||
@@ -248,9 +265,42 @@ describe('ProofRequestStatusScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to Gratification when the endpoint is whitelisted for points', async () => {
|
||||
selfAppState.selfApp.endpoint = '0xABC';
|
||||
getWhiteListedDisclosureAddresses.mockResolvedValue([
|
||||
{
|
||||
contract_address: '0xabc',
|
||||
points_per_disclosure: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ProofRequestStatusScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.press(screen.getByTestId('primary-button'));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
|
||||
points: 25,
|
||||
});
|
||||
expect(mockGoHome).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not clear self app state if a newer session replaces the completed one', async () => {
|
||||
render(<ProofRequestStatusScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateProofStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.press(screen.getByTestId('primary-button'));
|
||||
provingState.uuid = 'session-2';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"ios": {
|
||||
"build": 216,
|
||||
"lastDeployed": "2026-02-20T11:17:57.338Z"
|
||||
"build": 217,
|
||||
"lastDeployed": "2026-04-14T04:50:15.630Z"
|
||||
},
|
||||
"android": {
|
||||
"build": 147,
|
||||
|
||||
269
specs/projects/sdk/workstreams/native-hardware-handlers/SPEC.md
Normal file
269
specs/projects/sdk/workstreams/native-hardware-handlers/SPEC.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Native Hardware Handlers — Spike Findings
|
||||
|
||||
> Last updated: 2026-04-13
|
||||
> Owner: SDK / Platform
|
||||
> Linear: SELF-2614
|
||||
> Status: Spike (feasibility assessment)
|
||||
|
||||
## Question
|
||||
|
||||
Is it feasible to bring NFC passport reading, MRZ/passport OCR scanning, and camera capture into the webview SDK's native shells as bridge handlers — and what does the work look like?
|
||||
|
||||
## Answer: Yes — most of the work already exists
|
||||
|
||||
The KMP SDK (`packages/kmp-sdk/`) already contains **bridge handler implementations with shared-parser test coverage** for both NFC and Camera/MRZ on both platforms (handler-level integration tests do not exist — `NfcApduPolicy` and `MrzParser` are tested, but `NfcBridgeHandler` and `CameraMrzBridgeHandler` are not directly tested). These were stripped from handler registration during KMP Revival (SELF-2488) but the code was explicitly retained. The TypeScript bridge layer also already defines the domains, methods, adapters, and types.
|
||||
|
||||
The work is a **port**, not a greenfield build.
|
||||
|
||||
---
|
||||
|
||||
## Inventory
|
||||
|
||||
### What already exists
|
||||
|
||||
#### TypeScript (bridge protocol + adapters) — DONE
|
||||
|
||||
| Layer | File | Status |
|
||||
| ------------------------- | -------------------------------------------------- | ---------------------------------------------- |
|
||||
| Bridge domain enum | `webview-bridge/src/types.ts:14-24` | `nfc` and `camera` already in `BridgeDomain` |
|
||||
| NFC method types | `webview-bridge/src/types.ts:91-93` | `'scan' \| 'cancelScan' \| 'isSupported'` |
|
||||
| NFC params/events | `webview-bridge/src/types.ts:95-113` | `NfcScanParams`, `NfcScanProgress`, `NfcEvent` |
|
||||
| NFC bridge adapter | `webview-bridge/src/adapters/nfc-scanner.ts` | Full impl with abort signal, 120s timeout |
|
||||
| NFC progress subscription | `webview-bridge/src/adapters/nfc-scanner.ts:43-45` | `onNfcProgress()` |
|
||||
| Camera method types | `webview-bridge/src/types.ts:72` | `'scanMRZ' \| 'isAvailable'` |
|
||||
| Camera bridge adapter | `webview-bridge/src/adapters/camera.ts` | Full impl with `MrzScanParams`/`MrzScanResult` |
|
||||
| SDK NFC adapter interface | `mobile-sdk-alpha/src/types/public.ts:339-341` | `NFCScannerAdapter` |
|
||||
| SDK NFC types | `mobile-sdk-alpha/src/types/public.ts:315-332` | `NFCScanOpts`, `NFCScanResult` |
|
||||
|
||||
#### Native domain enums — DONE
|
||||
|
||||
Both native shells already have `nfc` and `camera` in their `BridgeDomain` enums:
|
||||
|
||||
- Android: `native-shell-android/.../bridge/BridgeModels.kt:12-42`
|
||||
- iOS: `native-shell-ios/.../Bridge/BridgeModels.swift:5-16`
|
||||
|
||||
#### KMP handler implementations — EXISTS, needs porting
|
||||
|
||||
| Handler | Platform | File | LOC | Dependencies |
|
||||
| ---------- | -------- | ------------------------------------------------------- | ---- | ------------------------------------------ |
|
||||
| NFC | Android | `kmp-sdk/.../androidMain/.../NfcBridgeHandler.kt` | ~498 | jMRTD, BouncyCastle, SCUBA |
|
||||
| NFC | iOS | `kmp-sdk/.../iosMain/.../NfcBridgeHandler.kt` | ~114 | Delegates to `NfcProvider` interface |
|
||||
| Camera/MRZ | Android | `kmp-sdk/.../androidMain/.../CameraMrzBridgeHandler.kt` | ~247 | CameraX, ML Kit Text Recognition |
|
||||
| Camera/MRZ | iOS | `kmp-sdk/.../iosMain/.../CameraMrzBridgeHandler.kt` | ~82 | Delegates to `CameraMrzProvider` interface |
|
||||
|
||||
Supporting KMP code:
|
||||
|
||||
- `NfcApduPolicy` — parameter validation (commonMain)
|
||||
- `NfcScanParams`, `NfcScanProgress`, `NfcScanState` — models (commonMain)
|
||||
- `MrzParser` — shared MRZ extraction + parsing (commonMain)
|
||||
- `NfcProvider` interface — iOS NFC delegation (iosMain)
|
||||
- `IosProviderRegistry` — iOS provider registry (iosMain)
|
||||
|
||||
#### Existing Swift references already in this repo — EXISTS, reusable
|
||||
|
||||
The iOS side is not a greenfield protocol implementation. The repo already contains working Swift-side helpers/providers that can be adapted into native-shell reference providers:
|
||||
|
||||
| Reference | File | LOC | Notes |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------ | --------- | --------------------------------------------------------------- |
|
||||
| NFC helper | `packages/self-sdk-swift/Sources/SelfSdkSwift/Helpers/NfcPassportHelper.swift` | 265 | Wraps `NFCPassportReader`, exposes progress + JSON result |
|
||||
| NFC provider | `packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/NfcProviderImpl.swift` | 67 | Thin `NfcProvider` wrapper around helper |
|
||||
| MRZ helper | `packages/self-sdk-swift/Sources/SelfSdkSwift/Helpers/MrzCameraHelper.swift` | 329 | AVFoundation + Vision preview + OCR |
|
||||
| MRZ provider | `packages/self-sdk-swift/Sources/SelfSdkSwift/Providers/CameraMrzProviderImpl.swift` | 63 | Thin `CameraMrzProvider` wrapper around helper |
|
||||
| App-side passport reader refs | `app/ios/PassportReader.swift`, `app/ios/PassportReaderCore.swift` | 490 total | Existing wallet implementation using the forked passport reader |
|
||||
|
||||
The React Native app and Swift package already prove the underlying iOS NFC/camera workflows work in this repo. The shell work is primarily packaging, adaptation, and bridge registration.
|
||||
|
||||
### What does NOT exist
|
||||
|
||||
- **Native shell NFC handlers** — `native-shell-android` and `native-shell-ios` have no `NfcHandler` or `CameraHandler`. Only `SecureStorageHandler`, `CryptoHandler`, `LifecycleHandler` are registered.
|
||||
- **Handler registration** — `SelfVerificationActivity.kt:67-69` and `SelfSdk.swift:61-63` only register the 3 current domains.
|
||||
- **Native dependencies** — Native shells have minimal deps (appcompat, webkit, kotlinx-serialization on Android; zero external deps on iOS). NFC/camera will add significant dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Feasibility by Module
|
||||
|
||||
### NFC Passport Reading
|
||||
|
||||
**Verdict: Feasible. Full reference implementation exists in KMP.**
|
||||
|
||||
The KMP Android handler (`NfcBridgeHandler.kt`, 498 lines) is a complete ICAO 9303 passport reader:
|
||||
|
||||
- PACE authentication with CAN/MRZ key
|
||||
- BAC fallback with retry logic
|
||||
- Chip Authentication via DG14
|
||||
- DG1 (MRZ) + SOD (security object) extraction
|
||||
- Progress events pushed via `router.pushEvent()`
|
||||
- Full result with MRZ, certificates, digests, data group hashes
|
||||
|
||||
**Android approach:** Port the KMP handler directly. It uses `NfcAdapter.enableReaderMode()` which requires an `Activity` reference — `SelfVerificationActivity` already is one.
|
||||
|
||||
**iOS approach:** The KMP iOS handler delegates to a `NfcProvider` interface. The native shell should do the same. iOS NFC passport reading requires CoreNFC with `NFCTagReaderSession` (ISO 7816). Apple restricts this to apps with the `com.apple.developer.nfc.readersession.formats` entitlement. The SDK consumer's app must have this entitlement — the shell cannot add it.
|
||||
|
||||
**Key constraint (iOS):** CoreNFC requires the consumer's app to have NFC entitlements + Info.plist keys. The SDK must document this requirement and fail gracefully if not configured.
|
||||
|
||||
**Dependencies added:**
|
||||
|
||||
| Platform | Library | Purpose | Size impact |
|
||||
| -------- | --------------------------------- | --------------------------- | ------------ |
|
||||
| Android | `org.jmrtd:jmrtd` | ICAO 9303 passport protocol | ~400KB |
|
||||
| Android | `org.bouncycastle:bcprov-jdk18on` | Crypto for BAC/PACE | ~6MB |
|
||||
| Android | `net.sf.scuba:scuba-smartcards` | Smart card abstraction | ~100KB |
|
||||
| Android | `commons-io:commons-io` | IO utilities | ~300KB |
|
||||
| iOS | CoreNFC (system framework) | NFC tag reading | 0 (built-in) |
|
||||
| iOS | CryptoKit (system framework) | BAC/PACE crypto | 0 (built-in) |
|
||||
|
||||
**Android binary size impact:** ~7MB added to AAR (primarily BouncyCastle). This is significant given the shells are currently lightweight. Consider: ProGuard/R8 shrinking can reduce BouncyCastle substantially since only a subset of crypto is used.
|
||||
|
||||
**iOS binary size impact:** Negligible — uses system frameworks plus the existing passport-reader dependency pattern already used elsewhere in this repo. The missing work is wiring that existing Swift reference path into a native-shell-friendly provider surface.
|
||||
|
||||
### MRZ/OCR Camera Scanning
|
||||
|
||||
**Verdict: Feasible. Full reference implementation exists in KMP.**
|
||||
|
||||
The KMP Android handler (`CameraMrzBridgeHandler.kt`, 247 lines) uses CameraX + ML Kit:
|
||||
|
||||
- Opens camera via `ProcessCameraProvider`
|
||||
- Runs `TextRecognition` on each frame
|
||||
- Detects MRZ lines using regex (TD3: 2×44 chars, TD1: 3×30 chars)
|
||||
- Parses detected MRZ and returns structured JSON
|
||||
- Progress reporting via `MrzDetectionState`
|
||||
|
||||
**Android approach:** Port the KMP handler. Requires `Activity` (already available) and `LifecycleOwner` (Activity implements it). CameraX handles permissions, but the WebView needs to delegate camera permission requests — `SelfVerificationActivity` already handles `onRequestPermissionsResult`.
|
||||
|
||||
**iOS approach:** Delegate to provider interface (same pattern as KMP iOS). Consumer provides a `CameraMrzProvider` that wraps `AVCaptureSession` + `VNRecognizeTextRequest` (Vision framework). Alternatively, embed the implementation using system frameworks.
|
||||
|
||||
**Interaction model:** MRZ scanning requires a native preview surface. The shell will keep the existing request/response bridge contract and add a temporary native overlay above the WebView:
|
||||
|
||||
- `camera.scanMRZ()` presents a native full-screen/modal camera overlay from the shell container (`Activity` on Android, modal view controller on iOS).
|
||||
- The WebView remains mounted underneath. JS keeps a pending bridge promise; there is no frame streaming through the bridge and no WebView reload.
|
||||
- Progress continues through `router.pushEvent()` while the scan is active.
|
||||
- The request resolves when the overlay returns a parsed MRZ result, and rejects on user cancel / timeout / permission failure.
|
||||
- Dismissing the overlay is the end of the interaction; the shell returns to the same WebView state it had before the scan started.
|
||||
|
||||
This matches the KMP Android `scanMrzWithPreview()` behavior and keeps native UI concerns out of the web layer.
|
||||
|
||||
**Dependencies added:**
|
||||
|
||||
| Platform | Library | Purpose | Size impact |
|
||||
| -------- | ----------------------------------- | ------------------------- | --------------------- |
|
||||
| Android | `androidx.camera:camera-*` | CameraX for camera access | ~2MB |
|
||||
| Android | `com.google.mlkit:text-recognition` | On-device OCR | ~18MB (bundled model) |
|
||||
| iOS | AVFoundation (system) | Camera capture | 0 |
|
||||
| iOS | Vision (system) | Text recognition | 0 |
|
||||
|
||||
**Android binary size impact:** ~20MB — primarily the ML Kit text recognition model. This is the largest concern. Mitigation: use `com.google.mlkit:text-recognition` with the thin (unbundled) model variant which downloads on first use (~3MB APK impact, ~18MB download on first use).
|
||||
|
||||
**iOS binary size impact:** Negligible — uses system frameworks only.
|
||||
|
||||
### Camera (generic capture)
|
||||
|
||||
**Verdict: Not needed as a separate handler.** QR scanning uses the WebView's built-in camera access (`getUserMedia`). Document photo capture (if needed) can use the same `camera` bridge domain with a new method. No separate "camera" module is required beyond what MRZ scanning already provides.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Implementation split
|
||||
|
||||
**Decision: Use provider delegation for iOS, embedded implementation for Android.**
|
||||
|
||||
> **⚠️ Invariant override:** This decision intentionally departs from the current "native shells are thin wrappers with no business logic in Kotlin/Swift" rule (CLAUDE.md:26) and the native-shells-lite SPEC.md which marks NFC/camera out of scope. NFC passport reading and camera MRZ scanning require platform APIs (`NfcAdapter.enableReaderMode`, CameraX, ML Kit) that cannot run in the WebView — these are legitimate exceptions to the thin-wrapper rule. If this spike is greenlit, the following docs must be updated to reflect the exception:
|
||||
>
|
||||
> - `CLAUDE.md` — add a carve-out for hardware-access handlers (NFC, camera) that require platform APIs unavailable to WebView
|
||||
> - `specs/projects/sdk/workstreams/native-shells-lite/SPEC.md` — remove NFC/camera from out-of-scope or note they moved to this workstream
|
||||
> - `specs/projects/sdk/OVERVIEW.md` — update the native-shell architecture section if it references the thin-wrapper invariant
|
||||
|
||||
Rationale:
|
||||
|
||||
- **Android:** The KMP handler is a self-contained implementation using standard Android APIs (`NfcAdapter`, CameraX, ML Kit). No consumer-specific configuration needed beyond NFC hardware availability. Embedding keeps integration simple — one dependency, it works. This is not "business logic in native" — it is hardware-access code that physically cannot run in JavaScript.
|
||||
- **iOS:** CoreNFC requires app-level entitlements that the SDK cannot add. The consumer must configure NFC in their Xcode project. A provider interface lets consumers wire in their own implementation (or use a reference implementation we publish separately). This matches the existing KMP iOS pattern.
|
||||
|
||||
This means:
|
||||
|
||||
- Android native shell handlers contain the full NFC/camera logic (ported from KMP)
|
||||
- iOS native shell handlers delegate to provider interfaces (matching KMP iOS pattern)
|
||||
- SDK consumers on iOS must supply providers (or use a reference impl package)
|
||||
|
||||
### Capability detection semantics
|
||||
|
||||
**Decision: `isSupported` / `isAvailable` report runtime hardware availability, not full end-to-end success guarantees.**
|
||||
|
||||
- `nfc.isSupported`
|
||||
- Android: `true` only on physical devices with NFC hardware/adapter available for reader mode.
|
||||
- iOS: `true` only when `NFCReaderSession.readingAvailable` is `true` on a physical device.
|
||||
- Simulator / no hardware: `false`.
|
||||
- Missing iOS entitlement / Info.plist configuration: not reliably detectable up front, so `isSupported` may still be `true`; `scan` must then fail with a clear configuration error instead of hanging or crashing.
|
||||
- `camera.isAvailable`
|
||||
- `true` when a camera device exists and the shell can attempt to present a preview.
|
||||
- `false` on simulators or devices with no usable camera.
|
||||
- Permission is not part of this preflight check; `scanMRZ` is responsible for requesting permission and surfacing a clear denial/cancel error if access is unavailable.
|
||||
|
||||
This keeps the adapter contract predictable for WebView UI: preflight checks gate obvious unsupported devices, and operation-specific failures remain explicit runtime errors.
|
||||
|
||||
### KMP parity
|
||||
|
||||
**Decision: Do not re-register the KMP NFC/camera handlers in this workstream.**
|
||||
|
||||
Rationale:
|
||||
|
||||
- SELF-2488 intentionally reduced the KMP runtime surface.
|
||||
- This spike is scoped to native-shell bridge parity, not to broadening the KMP artifact again.
|
||||
- Re-enabling KMP handlers is easy later if product scope changes, but doing it here would silently expand footprint and support expectations across another distribution channel.
|
||||
|
||||
---
|
||||
|
||||
## Rough Sizing
|
||||
|
||||
| Chunk | Platform | Est. LOC | Dependencies added |
|
||||
| ------------------------------- | -------- | ---------- | ------------------------------------------------- |
|
||||
| NFC handler | Android | ~550 | jMRTD, BouncyCastle, SCUBA |
|
||||
| NFC handler | iOS | ~150 | None (provider interface) |
|
||||
| Camera/MRZ handler | Android | ~300 | CameraX, ML Kit |
|
||||
| Camera/MRZ handler | iOS | ~100 | None (provider interface) |
|
||||
| Handler registration | Both | ~50 | None |
|
||||
| Tests | Both | ~400 | Test fixtures |
|
||||
| iOS reference provider (NFC) | iOS | ~350 | CoreNFC / existing Swift helper path |
|
||||
| iOS reference provider (Camera) | iOS | ~400 | AVFoundation, Vision / existing Swift helper path |
|
||||
| **Total** | | **~2,300** | |
|
||||
|
||||
This is small enough for one large PR, but the cleaner execution plan is **3 PR-sized chunks**:
|
||||
|
||||
1. NFC handlers and registration (Android embedded + iOS provider contract) — ~1.1k LOC
|
||||
2. Camera/MRZ handlers and native overlay presentation — ~850 LOC
|
||||
3. iOS reference providers + consumer docs/validation fixtures — ~700 LOC
|
||||
|
||||
The iOS reference provider estimate is based on adapting code that already exists in `packages/self-sdk-swift`, not on writing a brand-new ICAO/CoreNFC implementation from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Risks and Open Questions
|
||||
|
||||
1. **Android binary size** — NFC adds ~7MB (BouncyCastle), Camera/MRZ adds ~20MB (ML Kit). Total ~27MB. The native shells were designed to be lightweight after SD-01/SD-02 removed bundled WebView assets. This is the main tradeoff behind the Android embedded decision and needs explicit acceptance before implementation starts.
|
||||
|
||||
2. **ML Kit bundled vs. unbundled** — Bundled model (~18MB in APK) vs. unbundled (download on first use). Unbundled reduces APK size but adds a first-run download + failure mode.
|
||||
|
||||
3. **iOS NFC entitlement/configuration** — Consumer's app MUST have NFC entitlements and the required Info.plist configuration. The shell should not guess at startup; it should fail `scan` with a clear configuration error when the app is misconfigured.
|
||||
|
||||
4. **Overlay lifecycle correctness** — The interaction model is defined, but the shell still needs clean presentation/dismissal, cancellation, timeout, and rotation/backgrounding behavior for the native MRZ overlay.
|
||||
|
||||
5. **Dependency ownership on iOS** — The reference path currently spans `packages/self-sdk-swift`, `app/ios`, and the external passport-reader fork. The execution plan needs to decide which package owns the reusable reference provider so the shell does not duplicate long-term maintenance.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- **Define passport/MRZ data-handling rules.** Handlers return PII (document numbers, MRZ data, NFC certificate material, data group hashes). Before implementation: (a) ensure no passport-derived data is written to logs, analytics, or crash reporters in the native shell, (b) add redaction validation to handler tests, (c) document the data-handling contract in consumer docs so SDK integrators know what flows through the bridge and their retention obligations.
|
||||
- Add Android `consumer-rules.pro` coverage for BouncyCastle / jMRTD / SCUBA reflection paths.
|
||||
- Decide ML Kit bundled vs. unbundled packaging before the Android camera PR lands.
|
||||
- Document iOS NFC entitlement and Info.plist requirements in shell consumer docs.
|
||||
- Add capability-detection tests that lock the `isSupported` / `isAvailable` semantics above.
|
||||
- Add overlay lifecycle tests or manual validation steps for cancel/background/rotation behavior.
|
||||
|
||||
## Next Steps
|
||||
|
||||
This spike should not become the execution doc. If greenlit, the next step is to create PR-sized plan files under `plans/` and keep this file as the durable inventory/decision record.
|
||||
|
||||
Suggested follow-ups:
|
||||
|
||||
1. `plans/SELF-2614-nfc-handlers.md` — Android embedded NFC handler + iOS provider contract + registration
|
||||
2. `plans/SELF-2614-mrz-camera-overlay.md` — Android/iOS camera handler + overlay presentation lifecycle
|
||||
3. `plans/SELF-2614-ios-reference-providers.md` — Swift reference providers, consumer docs, and validation
|
||||
Reference in New Issue
Block a user