Merge pull request #1996 from selfxyz/release/staging-2026-04-20

Release to Staging v2.9.18 - 2026-04-20
This commit is contained in:
Justin Hernandez
2026-04-19 19:59:47 -07:00
committed by GitHub
11 changed files with 151 additions and 54 deletions

View File

@@ -132,8 +132,8 @@ android {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 148
versionName "2.9.17"
versionCode 149
versionName "2.9.18"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {

View File

@@ -4,7 +4,7 @@
"expo": {
"name": "Self App",
"slug": "self-app",
"version": "2.9.17",
"version": "2.9.18",
"platforms": ["ios", "android"],
"ios": {
"bundleIdentifier": "com.proofofpassportapp"

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.9.17</string>
<string>2.9.18</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -593,7 +593,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.17;
MARKETING_VERSION = 2.9.18;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -735,7 +735,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.17;
MARKETING_VERSION = 2.9.18;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.17",
"version": "2.9.18",
"private": true,
"type": "module",
"scripts": {

View File

@@ -11,7 +11,8 @@ import {
} 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 { StaticScreenProps } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { X } from '@tamagui/lucide-icons';
@@ -30,13 +31,15 @@ import SelfLogo from '@/assets/logos/self.svg';
import { SystemBars } from '@/components/SystemBars';
import type { RootStackParamList } from '@/navigation';
const GratificationScreen: React.FC = () => {
type GratificationScreenProps = StaticScreenProps<{
points?: number;
}>;
const GratificationScreen: React.FC<GratificationScreenProps> = ({ route }) => {
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 pointsEarned = route.params?.points ?? 0;
const [isAnimationFinished, setIsAnimationFinished] = useState(false);
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');

View File

@@ -31,10 +31,16 @@ import {
} from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import {
hasUserAnIdentityDocumentRegistered,
hasUserDoneThePointsDisclosure,
} from '@/services/points';
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
const PREREQ_CHECK_TIMEOUT_MS = 3000;
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
@@ -68,23 +74,37 @@ const SuccessScreen: React.FC = () => {
buttonTap();
const completedSessionId = sessionId;
const cleanupLater = () => {
setTimeout(() => {
if (useProvingStore.getState().uuid === completedSessionId) {
selfClient.getSelfAppState().cleanSelfApp();
}
}, 2000);
};
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);
// Bound the prereq checks so a stalled network call can't trap the user
// on this screen. On timeout we fall through to goHome() — the safe
// default, since Gratification would just bounce them via the guardrail.
const timeout = new Promise<false>(resolve =>
setTimeout(() => resolve(false), PREREQ_CHECK_TIMEOUT_MS),
);
const [hasDocument, hasDisclosed] = await Promise.all([
Promise.race([hasUserAnIdentityDocumentRegistered(), timeout]),
Promise.race([hasUserDoneThePointsDisclosure(), timeout]),
]);
if (hasDocument && hasDisclosed) {
navigation.navigate('Gratification', {
points: whitelistedPoints,
});
cleanupLater();
return;
}
}
goHome();
cleanupLater();
}, [
whitelistedPoints,
navigation,

View File

@@ -5,6 +5,7 @@
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';
import type { transportFunctionType } from 'react-native-logs';
import { v4 as uuidv4 } from 'uuid';
import { registerDocumentChangeCallback } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
@@ -31,7 +32,7 @@ interface LokiPayload {
}
// Per-session ID for grouping logs in Grafana (not persistent, not user-identifiable)
const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const sessionId = uuidv4();
// Batch management state
let batch: LokiLogEntry[] = [];

View File

@@ -2,7 +2,7 @@
// 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 { useNavigation } from '@react-navigation/native';
import { render, waitFor } from '@testing-library/react-native';
import GratificationScreen from '@/screens/app/GratificationScreen';
@@ -48,7 +48,6 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
useRoute: jest.fn(),
}));
// Mock Tamagui components to avoid theme provider requirement
@@ -103,7 +102,9 @@ jest.mock('@/assets/logos/self.svg', () => 'SelfLogo');
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
const mockUseRoute = useRoute as jest.MockedFunction<typeof useRoute>;
const renderScreen = (params: { points?: number } = {}) =>
render(<GratificationScreen route={{ params } as any} />);
describe('GratificationScreen', () => {
const mockNavigate = jest.fn();
@@ -116,18 +117,10 @@ describe('GratificationScreen', () => {
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 />);
const { getByText } = renderScreen();
await waitFor(() => {
expect(getByText('0')).toBeTruthy();
@@ -135,11 +128,7 @@ describe('GratificationScreen', () => {
});
it('should use custom points value when provided', async () => {
mockUseRoute.mockReturnValue({
params: { points: 50 },
} as any);
const { getByText } = render(<GratificationScreen />);
const { getByText } = renderScreen({ points: 50 });
await waitFor(() => {
expect(getByText('50')).toBeTruthy();
@@ -147,11 +136,7 @@ describe('GratificationScreen', () => {
});
it('should display referral points value (24) when passed', async () => {
mockUseRoute.mockReturnValue({
params: { points: 24 },
} as any);
const { getByText } = render(<GratificationScreen />);
const { getByText } = renderScreen({ points: 24 });
await waitFor(() => {
expect(getByText('24')).toBeTruthy();

View File

@@ -131,6 +131,11 @@ jest.mock('@/services/points/utils', () => ({
getWhiteListedDisclosureAddresses: jest.fn(),
}));
jest.mock('@/services/points', () => ({
hasUserAnIdentityDocumentRegistered: jest.fn(),
hasUserDoneThePointsDisclosure: jest.fn(),
}));
jest.mock('@/stores/proofHistoryStore', () => ({
useProofHistoryStore: jest.fn(),
}));
@@ -157,6 +162,11 @@ const { getWhiteListedDisclosureAddresses } = jest.requireMock(
) as {
getWhiteListedDisclosureAddresses: jest.Mock;
};
const { hasUserAnIdentityDocumentRegistered, hasUserDoneThePointsDisclosure } =
jest.requireMock('@/services/points') as {
hasUserAnIdentityDocumentRegistered: jest.Mock;
hasUserDoneThePointsDisclosure: jest.Mock;
};
const { useProofHistoryStore } = jest.requireMock(
'@/stores/proofHistoryStore',
) as {
@@ -211,6 +221,8 @@ describe('ProofRequestStatusScreen', () => {
updateProofStatus: mockUpdateProofStatus,
});
getWhiteListedDisclosureAddresses.mockResolvedValue([]);
hasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
hasUserDoneThePointsDisclosure.mockResolvedValue(true);
const useProvingStore = Object.assign(
(selector: (state: typeof provingState) => unknown) =>
@@ -282,8 +294,10 @@ describe('ProofRequestStatusScreen', () => {
fireEvent.press(screen.getByTestId('primary-button'));
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
points: 25,
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
points: 25,
});
});
expect(mockGoHome).not.toHaveBeenCalled();
@@ -294,6 +308,80 @@ describe('ProofRequestStatusScreen', () => {
expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
});
it('falls back to Home when whitelisted but points prerequisites are not met', async () => {
selfAppState.selfApp.endpoint = '0xABC';
getWhiteListedDisclosureAddresses.mockResolvedValue([
{
contract_address: '0xabc',
points_per_disclosure: 25,
},
]);
hasUserDoneThePointsDisclosure.mockResolvedValue(false);
render(<ProofRequestStatusScreen />);
await waitFor(() => {
expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
});
fireEvent.press(screen.getByTestId('primary-button'));
await waitFor(() => {
expect(mockGoHome).toHaveBeenCalledTimes(1);
});
expect(mockNavigate).not.toHaveBeenCalledWith(
'Gratification',
expect.anything(),
);
act(() => {
jest.advanceTimersByTime(2000);
});
expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
});
it('falls back to Home when a prerequisite check stalls past the timeout', async () => {
selfAppState.selfApp.endpoint = '0xABC';
getWhiteListedDisclosureAddresses.mockResolvedValue([
{
contract_address: '0xabc',
points_per_disclosure: 25,
},
]);
// Simulate a hung network call — never resolves.
hasUserDoneThePointsDisclosure.mockImplementation(
() => new Promise(() => {}),
);
render(<ProofRequestStatusScreen />);
await waitFor(() => {
expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
});
fireEvent.press(screen.getByTestId('primary-button'));
// Advance past the 3s prereq timeout.
await act(async () => {
jest.advanceTimersByTime(3000);
});
await waitFor(() => {
expect(mockGoHome).toHaveBeenCalledTimes(1);
});
expect(mockNavigate).not.toHaveBeenCalledWith(
'Gratification',
expect.anything(),
);
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 />);

View File

@@ -4,7 +4,7 @@
"lastDeployed": "2026-04-14T21:02:24.000Z"
},
"android": {
"build": 148,
"lastDeployed": "2026-04-14T21:02:24.000Z"
"build": 149,
"lastDeployed": "2026-04-20T00:22:24.761Z"
}
}