SELF-1680: Starfall mobile push notifications (#1548)

* move to personal mcp

* add new nova pin screen

* rename screen

* update nova route

* unblock local dev building

* rename nova to starfall

* move to dev dependency

* move to dependencies

* add correct package

* save wip

* save wip

* save wip fixes

* rename self logo

* fix screen logos

* fix order

* add starfall api to fetch push notification code

* agent feedback

* fix tests, minor agent feedback

* abstract component

* rename topic

* re-add button props

* fix linting
This commit is contained in:
Justin Hernandez
2026-01-05 20:17:28 -08:00
committed by GitHub
parent 753461f4cb
commit 43fb39d3d4
25 changed files with 948 additions and 197 deletions

View File

@@ -0,0 +1,3 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.0459 12.3135C0.360352 11.6221 0.0146484 10.9219 0.00878906 10.2129C0.00292969 9.50391 0.342773 8.80664 1.02832 8.12109L8.12988 1.02832C8.80957 0.342773 9.50391 0.00292969 10.2129 0.00878906C10.9277 0.0146484 11.6309 0.360352 12.3223 1.0459L19.3799 8.10352C20.0654 8.79492 20.4111 9.49805 20.417 10.2129C20.4229 10.9219 20.083 11.6191 19.3975 12.3047L12.3047 19.3975C11.6191 20.083 10.9219 20.4229 10.2129 20.417C9.50391 20.4111 8.80371 20.0654 8.1123 19.3799L1.0459 12.3135ZM9.24609 14.5723C9.42188 14.5723 9.58301 14.5312 9.72949 14.4492C9.88184 14.3613 10.0107 14.2412 10.1162 14.0889L14.2207 7.74316C14.2852 7.64355 14.3379 7.53809 14.3789 7.42676C14.4258 7.31543 14.4492 7.2041 14.4492 7.09277C14.4492 6.84668 14.3555 6.64746 14.168 6.49512C13.9863 6.34277 13.7783 6.2666 13.5439 6.2666C13.2334 6.2666 12.9727 6.43359 12.7617 6.76758L9.21973 12.4277L7.59375 10.3887C7.47656 10.2422 7.35938 10.1396 7.24219 10.0811C7.125 10.0166 6.99316 9.98438 6.84668 9.98438C6.60059 9.98438 6.39258 10.0723 6.22266 10.248C6.05273 10.418 5.96777 10.626 5.96777 10.8721C5.96777 10.9893 5.98828 11.1035 6.0293 11.2148C6.07617 11.3203 6.14062 11.4287 6.22266 11.54L8.34082 14.0889C8.46973 14.2529 8.60742 14.376 8.75391 14.458C8.90039 14.5342 9.06445 14.5723 9.24609 14.5723Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2025 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 { View, XStack } from 'tamagui';
import { black, zinc800 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import CheckmarkIcon from '@/assets/icons/checkmark_white.svg';
import OperaLogo from '@/assets/logos/opera_minipay.svg';
import SelfLogo from '@/assets/logos/self.svg';
export const StarfallLogoHeader: React.FC = () => (
<XStack gap={10} alignItems="center" marginBottom={20}>
{/* Opera MiniPay logo */}
<View width={46} height={46} borderRadius={3} overflow="hidden">
<OperaLogo width={46} height={46} />
</View>
{/* Checkmark icon */}
<View width={32} height={32}>
<CheckmarkIcon width={32} height={32} />
</View>
{/* Self logo */}
<View
width={46}
height={46}
backgroundColor={black}
borderRadius={3}
borderWidth={1}
borderColor={zinc800}
alignItems="center"
justifyContent="center"
>
<SelfLogo width={28} height={28} />
</View>
</XStack>
);

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025 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 { Text, XStack, YStack } from 'tamagui';
import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
export interface StarfallPINProps {
code: string;
}
export const StarfallPIN: React.FC<StarfallPINProps> = ({ code }) => {
// Split the code into individual digits (expects 4 digits)
const digits = code.split('').slice(0, 4);
// Pad with empty strings if less than 4 digits
while (digits.length < 4) {
digits.push('');
}
return (
<XStack
gap={6}
alignItems="center"
justifyContent="center"
padding={4}
borderRadius={12}
borderWidth={1}
borderColor="#52525b"
backgroundColor="rgba(0, 0, 0, 0.4)"
width="100%"
>
{digits.map((digit, index) => (
<YStack
key={index}
flex={1}
height={80}
alignItems="center"
justifyContent="center"
borderRadius={8}
borderWidth={1}
borderColor="rgba(255, 255, 255, 0.2)"
paddingHorizontal={12}
>
<Text
fontFamily={dinot}
fontSize={32}
fontWeight="500"
color={white}
letterSpacing={-1}
lineHeight={32}
>
{digit}
</Text>
</YStack>
))}
</XStack>
);
};

View File

@@ -27,6 +27,7 @@ import documentsScreens from '@/navigation/documents';
import homeScreens from '@/navigation/home';
import onboardingScreens from '@/navigation/onboarding';
import sharedScreens from '@/navigation/shared';
import starfallScreens from '@/navigation/starfall';
import verificationScreens from '@/navigation/verification';
import type { ModalNavigationParams } from '@/screens/app/ModalScreen';
import type { WebViewScreenParams } from '@/screens/shared/WebViewScreen';
@@ -41,6 +42,7 @@ export const navigationScreens = {
...verificationScreens,
...accountScreens,
...sharedScreens,
...starfallScreens,
...devScreens, // allow in production for testing
};
@@ -158,6 +160,7 @@ export type RootStackParamList = Omit<
Gratification: {
points?: number;
};
StarfallPushCode: undefined;
// Home screens
Home: {

View File

@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import StarfallPushCodeScreen from '@/screens/starfall/StarfallPushCodeScreen';
const starfallScreens = {
StarfallPushCode: {
screen: StarfallPushCodeScreen,
options: {
headerShown: false,
} as NativeStackNavigationOptions,
},
};
export default starfallScreens;

View File

@@ -26,8 +26,8 @@ import {
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, dinotBold } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import LogoWhite from '@/assets/icons/logo_white.svg';
import GratificationBg from '@/assets/images/gratification_bg.svg';
import SelfLogo from '@/assets/logos/self.svg';
import type { RootStackParamList } from '@/navigation';
const GratificationScreen: React.FC = () => {
@@ -160,7 +160,7 @@ const GratificationScreen: React.FC = () => {
>
{/* Logo icon */}
<View marginBottom={12} style={styles.logoContainer}>
<LogoWhite width={37} height={37} />
<SelfLogo width={37} height={37} />
</View>
{/* Points display */}

View File

@@ -660,11 +660,11 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
>
<YStack gap="$2">
<TopicToggleButton
label="Nova"
label="Starfall"
isSubscribed={
hasNotificationPermission && subscribedTopics.includes('nova')
}
onToggle={() => handleTopicToggle(['nova'], 'Nova')}
onToggle={() => handleTopicToggle(['nova'], 'Starfall')}
/>
<TopicToggleButton
label="General"
@@ -675,7 +675,7 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
onToggle={() => handleTopicToggle(['general'], 'General')}
/>
<TopicToggleButton
label="Both (Nova + General)"
label="Both (Starfall + General)"
isSubscribed={
hasNotificationPermission &&
subscribedTopics.includes('nova') &&

View File

@@ -0,0 +1,255 @@
// SPDX-FileCopyrightText: 2025 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, useRef, useState } from 'react';
import { ImageBackground, StyleSheet } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { Text, View, YStack } from 'tamagui';
import Clipboard from '@react-native-clipboard/clipboard';
import { useNavigation } from '@react-navigation/native';
import {
PrimaryButton,
SecondaryButton,
} from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
green500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { advercase, dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import StarfallBackground from '@/assets/images/bg_starfall_push.png';
import { StarfallLogoHeader } from '@/components/starfall/StarfallLogoHeader';
import { StarfallPIN } from '@/components/starfall/StarfallPIN';
import { confirmTap } from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { fetchPushCode } from '@/services/starfall/pushCodeService';
const DASH_CODE = '----';
const StarfallPushCodeScreen: React.FC = () => {
const navigation = useNavigation();
const [code, setCode] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleFetchCode = async () => {
try {
setIsLoading(true);
setError(null);
confirmTap();
const walletAddress = await getOrGeneratePointsAddress();
const fetchedCode = await fetchPushCode(walletAddress);
setCode(fetchedCode);
} catch (err) {
console.error('Failed to fetch push code:', err);
setError('Failed to generate code. Please try again.');
setCode(null); // Clear stale code on error
} finally {
setIsLoading(false);
}
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
}, []);
const handleRetry = () => {
handleFetchCode();
};
const handleCopyCode = async () => {
if (!code || code === DASH_CODE) {
return;
}
try {
confirmTap();
await Clipboard.setString(code);
setIsCopied(true);
// Clear any existing timeout before creating a new one
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
// Reset after 1.65 seconds
copyTimeoutRef.current = setTimeout(() => {
setIsCopied(false);
copyTimeoutRef.current = null;
}, 1650);
} catch (copyError) {
console.error('Failed to copy to clipboard:', copyError);
}
};
const handleDismiss = () => {
confirmTap();
navigation.goBack();
};
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
{/* Colorful background image */}
<ImageBackground
source={StarfallBackground}
style={StyleSheet.absoluteFill}
resizeMode="cover"
>
{/* Fade to black overlay - stronger at bottom */}
<LinearGradient
colors={['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.6)', black]}
locations={[0.1, 0.45, 0.6]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={StyleSheet.absoluteFill}
/>
</ImageBackground>
{/* Content container */}
<YStack flex={1} justifyContent="center" alignItems="center">
{/* App logos section */}
<StarfallLogoHeader />
{/* Title and content */}
<YStack
paddingHorizontal={20}
paddingVertical={20}
gap={12}
alignItems="center"
width="100%"
>
<Text
fontFamily={advercase}
fontSize={28}
fontWeight="400"
color={white}
textAlign="center"
letterSpacing={1}
>
Your Starfall code awaits
</Text>
<YStack gap={16} width="100%" alignItems="center">
<View paddingHorizontal={40} width="100%">
<Text
fontFamily={dinot}
fontSize={14}
fontWeight="500"
color={white}
textAlign="center"
>
Open Starfall in Opera MiniPay and enter this four digit code
to continue your journey.
</Text>
</View>
<View width="100%">
<StarfallPIN
code={
code === null || isLoading || error !== null
? DASH_CODE
: code
}
/>
</View>
{/* Error message */}
{error && (
<Text
fontFamily={dinot}
fontSize={14}
fontWeight="500"
color="#ef4444"
textAlign="center"
>
{error}
</Text>
)}
</YStack>
</YStack>
</YStack>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
backgroundColor={black}
style={{ backgroundColor: black }}
>
{/* Bottom buttons */}
<YStack gap={10} width="100%">
{/* Debug: Fetch code button or Retry button on error */}
{error ? (
<PrimaryButton
onPress={handleRetry}
disabled={isLoading}
fontSize={16}
style={{
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
Retry
</PrimaryButton>
) : (
<PrimaryButton
onPress={handleFetchCode}
disabled={isLoading}
fontSize={16}
style={{
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
{isLoading ? 'Fetching...' : 'Fetch code'}
</PrimaryButton>
)}
<PrimaryButton
onPress={handleCopyCode}
disabled={isCopied || !code || code === DASH_CODE || isLoading}
fontSize={16}
style={{
backgroundColor: isCopied ? green500 : undefined,
borderColor: '#374151',
borderWidth: 1,
borderRadius: 60,
height: 46,
paddingVertical: 0,
}}
>
{isCopied ? 'Code copied!' : 'Copy code'}
</PrimaryButton>
<SecondaryButton
onPress={handleDismiss}
textColor={black}
fontSize={16}
style={{ borderRadius: 60, height: 46, paddingVertical: 0 }}
>
Dismiss
</SecondaryButton>
</YStack>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default StarfallPushCodeScreen;

View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { POINTS_API_BASE_URL } from '@/services/points/constants';
const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
/**
* Fetches a one-time push code for the specified wallet address.
* The code has a TTL of 30 minutes and refreshes with each call.
*
* @param walletAddress - The wallet address to generate a push code for
* @returns The 4-digit push code as a string
* @throws Error if the API request fails or times out
*/
export async function fetchPushCode(walletAddress: string): Promise<string> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, REQUEST_TIMEOUT_MS);
try {
const response = await fetch(
`${POINTS_API_BASE_URL}/push/wallet/${walletAddress}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
},
);
// Clear timeout on successful response
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to fetch push code: ${response.status} ${response.statusText}`,
);
}
const code = await response.json();
// The API returns a JSON string like "5932"
if (typeof code !== 'string' || code.length !== 4) {
throw new Error('Invalid push code format received from API');
}
return code;
} catch (error) {
// Clear timeout on error
clearTimeout(timeoutId);
// Handle abort/timeout specifically
if (error instanceof Error && error.name === 'AbortError') {
console.error('Push code request timed out');
throw new Error(
'Request timed out. Please check your connection and try again.',
);
}
console.error('Error fetching push code:', error);
throw error;
}
}