mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfxyz/mobile-app",
|
||||
"version": "2.9.17",
|
||||
"version": "2.9.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user