Files
timmy-tower/artifacts/mobile/app/_layout.tsx
Alexander Whitestone 6433d9172c feat: mobile Nostr identity — Amber NIP-55 + nsec fallback (Fixes #29)
- Add NostrIdentityContext with SecureStore-backed nsec storage
  and pure-JS bech32/secp256k1 for nsec→npub derivation; private key
  never enters React state or logs
- Android: NIP-55 Amber deep-link integration (get_public_key +
  sign_event) with install-prompt fallback to Play Store when Amber
  is absent; Android queries manifest entry for com.greenart7c3.nostrsigner
- iOS/both: manual nsec entry stored exclusively in expo-secure-store
- Settings tab (gear icon) added to both NativeTabLayout and
  ClassicTabLayout showing: connected npub (truncated), signing method
  badge, Disconnect button (with confirmation + SecureStore wipe)
- Root layout wrapped with NostrIdentityProvider
- app.json: add expo-secure-store plugin + Android intentFilters for
  mobile://amber-callback deep-link return path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:24:45 -04:00

92 lines
2.8 KiB
TypeScript

import {
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
useFonts,
} from "@expo-google-fonts/inter";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import React, { useEffect, useState } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TimmyProvider } from "@/context/TimmyContext";
import { NostrIdentityProvider } from "@/context/NostrIdentityContext";
import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys";
SplashScreen.preventAutoHideAsync();
const queryClient = new QueryClient();
function RootLayoutNav() {
const segments = useSegments();
const [onboardingChecked, setOnboardingChecked] = useState(false);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
useEffect(() => {
AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY).then((value) => {
setNeedsOnboarding(value !== "true");
setOnboardingChecked(true);
});
}, []);
useEffect(() => {
if (!onboardingChecked) return;
const inOnboarding = segments[0] === "onboarding";
if (needsOnboarding && !inOnboarding) {
router.replace("/onboarding");
} else if (!needsOnboarding && inOnboarding) {
router.replace("/(tabs)");
}
}, [onboardingChecked, needsOnboarding, segments]);
return (
<Stack screenOptions={{ headerBackTitle: "Back" }}>
<Stack.Screen name="onboarding" options={{ headerShown: false, animation: "none" }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
Inter_400Regular,
Inter_500Medium,
Inter_600SemiBold,
Inter_700Bold,
});
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) return null;
return (
<SafeAreaProvider>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<NostrIdentityProvider>
<TimmyProvider>
<RootLayoutNav />
</TimmyProvider>
</NostrIdentityProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</QueryClientProvider>
</ErrorBoundary>
</SafeAreaProvider>
);
}