App/use whitelisted addresses for points (#1408)

* feat: enhance PointHistoryList and ProveScreen with disclosure event handling

- Updated PointHistoryList to include loading of disclosure events on refresh.
- Enhanced ProveScreen to set user's points address if not already defined.
- Added endpoint field to ProofHistory and database schema for better tracking.
- Introduced utility functions for fetching whitelisted disclosure addresses and managing disclosure events.

* fix: update navigation flow in PointsNavBar and GratificationScreen

- Changed navigation action in PointsNavBar from `goBack` to `navigate('Home')` for a more direct user experience.
- Updated GratificationScreen to navigate to 'Points' instead of going back, enhancing the flow after user interactions.
- Replaced the ArrowLeft icon with a new X icon for better visual consistency.
This commit is contained in:
turnoffthiscomputer
2025-11-12 23:35:38 +01:00
committed by GitHub
parent 87a81a50d5
commit 67eb0d46e4
11 changed files with 215 additions and 38 deletions

View File

@@ -31,7 +31,7 @@ export const PointsNavBar = (props: NativeStackHeaderProps) => {
color={black}
onPress={() => {
buttonTap();
props.navigation.goBack();
props.navigation.navigate('Home');
}}
/>
<View flex={1} alignItems="center" justifyContent="center">

View File

@@ -68,15 +68,15 @@ export const PointHistoryList: React.FC<PointHistoryListProps> = ({
}) => {
const selfClient = useSelfClient();
const [refreshing, setRefreshing] = useState(false);
// Subscribe to events directly from store - component will auto-update when store changes
const pointEvents = usePointEventStore(state => state.getAllPointEvents());
const isLoading = usePointEventStore(state => state.isLoading);
const refreshPoints = usePointEventStore(state => state.refreshPoints);
const refreshIncomingPoints = usePointEventStore(
state => state.refreshIncomingPoints,
);
// loadEvents only needs to be called once on mount.
// and it is called in Points.ts
const loadDisclosureEvents = usePointEventStore(
state => state.loadDisclosureEvents,
);
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString([], {
@@ -271,14 +271,15 @@ export const PointHistoryList: React.FC<PointHistoryListProps> = ({
[],
);
// Pull-to-refresh handler
const onRefresh = useCallback(() => {
selfClient.trackEvent(PointEvents.REFRESH_HISTORY);
setRefreshing(true);
Promise.all([refreshPoints(), refreshIncomingPoints()]).finally(() =>
setRefreshing(false),
);
}, [selfClient, refreshPoints, refreshIncomingPoints]);
Promise.all([
refreshPoints(),
refreshIncomingPoints(),
loadDisclosureEvents(),
]).finally(() => setRefreshing(false));
}, [selfClient, refreshPoints, refreshIncomingPoints, loadDisclosureEvents]);
const keyExtractor = useCallback((item: PointEvent) => item.id, []);

View File

@@ -14,13 +14,13 @@ 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 GratificationBg from '@/images/gratification_bg.svg';
import ArrowLeft from '@/images/icons/arrow_left.svg';
import LogoWhite from '@/images/icons/logo_white.svg';
import type { RootStackParamList } from '@/navigation';
import { black, slate700, white } from '@/utils/colors';
@@ -46,7 +46,7 @@ const GratificationScreen: React.FC = () => {
};
const handleBackPress = () => {
navigation.goBack();
navigation.navigate('Points' as never);
};
const handleAnimationFinish = useCallback(() => {
@@ -129,7 +129,7 @@ const GratificationScreen: React.FC = () => {
alignItems="center"
justifyContent="center"
>
<ArrowLeft width={24} height={24} />
<X width={24} height={24} />
</View>
</Pressable>
</View>

View File

@@ -8,7 +8,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
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 { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
@@ -25,6 +26,7 @@ import failAnimation from '@/assets/animations/proof_failed.json';
import succesAnimation from '@/assets/animations/proof_success.json';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
import { black, white } from '@/utils/colors';
@@ -33,6 +35,7 @@ import {
notificationError,
notificationSuccess,
} from '@/utils/haptic';
import { getWhiteListedDisclosureAddresses } from '@/utils/points/utils';
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -41,6 +44,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();
@@ -55,15 +60,28 @@ 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>(
null,
);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const onOkPress = useCallback(() => {
const onOkPress = useCallback(async () => {
buttonTap();
goHome();
setTimeout(() => {
selfClient.getSelfAppState().cleanSelfApp();
}, 2000); // Wait 2 seconds to user coming back to the home screen. If we don't wait the appname will change and user will see it.
}, [goHome, selfClient]);
if (whitelistedPoints !== null) {
navigation.navigate('Gratification', {
points: whitelistedPoints,
});
setTimeout(() => {
selfClient.getSelfAppState().cleanSelfApp();
}, 2000);
} else {
goHome();
setTimeout(() => {
selfClient.getSelfAppState().cleanSelfApp();
}, 2000);
}
}, [whitelistedPoints, navigation, goHome, selfClient]);
function cancelDeeplinkCallbackRedirect() {
setCountdown(null);
@@ -88,7 +106,28 @@ const SuccessScreen: React.FC = () => {
sessionId,
appName,
});
// Start countdown for redirect (only if we are on this screen and haven't started yet)
if (selfApp?.endpoint && whitelistedPoints === null) {
const checkWhitelist = async () => {
try {
const whitelistedContracts =
await getWhiteListedDisclosureAddresses();
const endpoint = selfApp.endpoint.toLowerCase();
const whitelistedContract = whitelistedContracts.find(
c => c.contract_address.toLowerCase() === endpoint,
);
if (whitelistedContract) {
setWhitelistedPoints(whitelistedContract.points_per_disclosure);
}
} catch (error) {
console.error('Error checking whitelist:', error);
}
};
checkWhitelist();
}
if (isFocused && !countdownStarted && selfApp?.deeplinkCallback) {
if (selfApp?.deeplinkCallback) {
try {
@@ -133,7 +172,9 @@ const SuccessScreen: React.FC = () => {
reason,
updateProofStatus,
selfApp?.deeplinkCallback,
selfApp?.endpoint,
countdownStarted,
whitelistedPoints,
]);
useEffect(() => {

View File

@@ -44,6 +44,7 @@ import { ProofStatus } from '@/stores/proofTypes';
import { black, slate300, white } from '@/utils/colors';
import { formatUserId } from '@/utils/formatUserId';
import { buttonTap } from '@/utils/haptic';
import { getPointsAddress } from '@/utils/points/utils';
const ProveScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -83,11 +84,12 @@ const ProveScreen: React.FC = () => {
sessionId: provingStore.uuid!,
userId: selectedApp.userId,
userIdType: selectedApp.userIdType,
endpoint: selectedApp.endpoint,
endpointType: selectedApp.endpointType,
status: ProofStatus.PENDING,
logoBase64: selectedApp.logoBase64,
disclosures: JSON.stringify(selectedApp.disclosures),
documentId: selectedDocumentId || '', // Fallback to empty if none selected
documentId: selectedDocumentId || '',
});
}
};
@@ -115,6 +117,29 @@ const ProveScreen: React.FC = () => {
selectedAppRef.current = selectedApp;
}, [selectedApp, isFocused, provingStore, selfClient]);
// Enhance selfApp with user's points address if not already set
useEffect(() => {
console.log('useEffect selectedApp', selectedApp);
if (!selectedApp || selectedApp.selfDefinedData) {
return;
}
const enhanceApp = async () => {
const address = await getPointsAddress();
// Only update if still the same session
if (selectedAppRef.current?.sessionId === selectedApp.sessionId) {
console.log('enhancing app with points address', address);
selfClient.getSelfAppState().setSelfApp({
...selectedApp,
selfDefinedData: address.toLowerCase(),
});
}
};
enhanceApp();
}, [selectedApp, selfClient]);
const disclosureOptions = useMemo(() => {
return (selectedApp?.disclosures as SelfAppDisclosureConfig) || [];
}, [selectedApp?.disclosures]);

View File

@@ -87,6 +87,7 @@ export const database: ProofDB = {
sessionId TEXT NOT NULL UNIQUE,
userId TEXT NOT NULL,
userIdType TEXT NOT NULL,
endpoint TEXT,
endpointType TEXT NOT NULL,
status TEXT NOT NULL,
errorCode TEXT,
@@ -108,10 +109,11 @@ export const database: ProofDB = {
try {
const [insertResult] = await db.executeSql(
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
proof.appName,
proof.endpoint || null,
proof.endpointType,
proof.status,
proof.errorCode || null,
@@ -133,12 +135,40 @@ export const database: ProofDB = {
} catch (error) {
if ((error as Error).message.includes('no column named documentId')) {
await addDocumentIdColumn();
// Then retry the insert (copy the executeSql call here)
const [insertResult] = await db.executeSql(
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
proof.appName,
proof.endpoint || null,
proof.endpointType,
proof.status,
proof.errorCode || null,
proof.errorReason || null,
timestamp,
proof.disclosures,
proof.logoBase64 || null,
proof.userId,
proof.userIdType,
proof.sessionId,
proof.documentId,
],
);
return {
id: insertResult.insertId.toString(),
timestamp,
rowsAffected: insertResult.rowsAffected,
};
} else if (
(error as Error).message.includes('no column named endpoint')
) {
await addEndpointColumn();
const [insertResult] = await db.executeSql(
`INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
proof.appName,
proof.endpoint || null,
proof.endpointType,
proof.status,
proof.errorCode || null,
@@ -184,3 +214,8 @@ async function addDocumentIdColumn() {
`ALTER TABLE ${TABLE_NAME} ADD COLUMN documentId TEXT NOT NULL DEFAULT ''`,
);
}
async function addEndpointColumn() {
const db = await openDatabase();
await db.executeSql(`ALTER TABLE ${TABLE_NAME} ADD COLUMN endpoint TEXT`);
}

View File

@@ -22,6 +22,7 @@ interface PointEventState {
events: PointEvent[];
isLoading: boolean;
loadEvents: () => Promise<void>;
loadDisclosureEvents: () => Promise<void>;
addEvent: (
title: string,
type: PointEventType,
@@ -38,7 +39,6 @@ interface PointEventState {
lastUpdated: number | null;
promise: Promise<IncomingPoints | null> | null;
};
// these are the real points that are on chain. each sunday noon UTC they get updated based on incoming points
points: number;
refreshPoints: () => Promise<void>;
fetchIncomingPoints: () => Promise<IncomingPoints | null>;
@@ -82,13 +82,11 @@ export const usePointEventStore = create<PointEventState>()((set, get) => ({
if (stored) {
try {
const parsed = JSON.parse(stored);
// Validate that parsed data is an array
if (!Array.isArray(parsed)) {
console.error('Invalid stored events format, expected array');
set({ events: [], isLoading: false });
return;
}
// Validate each event has required fields
const events: PointEvent[] = parsed.filter((event: unknown) => {
if (
typeof event === 'object' &&
@@ -103,12 +101,9 @@ export const usePointEventStore = create<PointEventState>()((set, get) => ({
return false;
}) as PointEvent[];
set({ events, isLoading: false });
// Resume polling for any pending events that were interrupted by app restart
// (New events are polled immediately in recordEvents.ts when created)
get()
.getUnprocessedEvents()
.forEach(event => {
// Use event.id as job_id (id is the job_id)
pollEventProcessingStatus(event.id).then(result => {
if (result === 'completed') {
get().markEventAsProcessed(event.id);
@@ -119,19 +114,36 @@ export const usePointEventStore = create<PointEventState>()((set, get) => ({
});
} catch (parseError) {
console.error('Error parsing stored events:', parseError);
// Clear corrupted data
await AsyncStorage.removeItem(STORAGE_KEY);
set({ events: [], isLoading: false });
}
} else {
set({ isLoading: false });
}
await get().loadDisclosureEvents();
} catch (error) {
console.error('Error loading point events:', error);
set({ isLoading: false });
}
},
loadDisclosureEvents: async () => {
try {
const { getDisclosurePointEvents } = await import(
'@/utils/points/getEvents'
);
const { useProofHistoryStore } = await import(
'@/stores/proofHistoryStore'
);
await useProofHistoryStore.getState().initDatabase();
const disclosureEvents = await getDisclosurePointEvents();
const existingEvents = get().events.filter(e => e.type !== 'disclosure');
set({ events: [...existingEvents, ...disclosureEvents] });
} catch (error) {
console.error('Error loading disclosure events:', error);
}
},
fetchIncomingPoints: async () => {
if (get().incomingPoints.promise) {
return await get().incomingPoints.promise;

View File

@@ -174,6 +174,7 @@ export const useProofHistoryStore = create<ProofHistoryState>()((set, get) => {
id: row.id.toString(),
sessionId: row.sessionId,
appName: row.appName,
endpoint: row.endpoint,
endpointType: row.endpointType,
status: row.status,
errorCode: row.errorCode,

View File

@@ -35,6 +35,7 @@ export interface ProofHistory {
sessionId: string;
userId: string;
userIdType: UserIdType;
endpoint?: string;
endpointType: EndpointType;
status: ProofStatus;
errorCode?: string;

View File

@@ -3,6 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PointEvent, PointEventType } from '@/utils/points/types';
import { getWhiteListedDisclosureAddresses } from '@/utils/points/utils';
/**
* Shared helper to get events from store filtered by type.
@@ -35,7 +36,49 @@ export const getBackupPointEvents = async (): Promise<PointEvent[]> => {
};
export const getDisclosurePointEvents = async (): Promise<PointEvent[]> => {
return getEventsByType('disclosure');
try {
const [whitelistedContracts, { useProofHistoryStore }] = await Promise.all([
getWhiteListedDisclosureAddresses(),
import('@/stores/proofHistoryStore'),
]);
if (whitelistedContracts.length === 0) {
return [];
}
const whitelistedMap = new Map(
whitelistedContracts.map(c => [
c.contract_address.toLowerCase(),
c.points_per_disclosure,
]),
);
const proofHistory = useProofHistoryStore.getState().proofHistory;
const disclosureEvents: PointEvent[] = [];
for (const proof of proofHistory) {
if (proof.status !== 'success' || !proof.endpoint) continue;
const endpoint = proof.endpoint.toLowerCase();
if (!whitelistedMap.has(endpoint)) continue;
const points = whitelistedMap.get(endpoint)!;
disclosureEvents.push({
id: proof.sessionId,
title: `${proof.appName} disclosure`,
type: 'disclosure',
timestamp: proof.timestamp,
points,
status: 'completed',
});
}
return disclosureEvents;
} catch (error) {
console.error('Error loading disclosure point events:', error);
return [];
}
};
export const getPushNotificationPointEvents = async (): Promise<

View File

@@ -10,6 +10,12 @@ import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { POINTS_API_BASE_URL } from '@/utils/points/constants';
import type { IncomingPoints } from '@/utils/points/types';
export type WhitelistedContract = {
contract_address: string;
points_per_disclosure: number;
num_disclosures: number;
};
export const formatTimeUntilDate = (targetDate: Date): string => {
const now = new Date();
const diffMs = targetDate.getTime() - now.getTime();
@@ -103,9 +109,23 @@ export const getTotalPoints = async (address: string): Promise<number> => {
};
export const getWhiteListedDisclosureAddresses = async (): Promise<
string[]
WhitelistedContract[]
> => {
return [];
try {
const response = await fetch(
`${POINTS_API_BASE_URL}/whitelisted-addresses`,
);
if (!response.ok) {
return [];
}
const data = await response.json();
return data.contracts || [];
} catch (error) {
console.error('Error fetching whitelisted addresses:', error);
return [];
}
};
export const hasUserAnIdentityDocumentRegistered =
@@ -146,7 +166,6 @@ export const hasUserDoneThePointsDisclosure = async (): Promise<boolean> => {
};
export const pointsSelfApp = async () => {
const userAddress = (await getPointsAddress())?.toLowerCase();
const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0';
const builder = new SelfAppBuilder({
appName: '✨ Self Points',
@@ -158,7 +177,6 @@ export const pointsSelfApp = async () => {
disclosures: {},
logoBase64:
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png',
selfDefinedData: userAddress,
header: '',
});