feat(mobile): Nostr identity — Amber NIP-55 deep link + nsec fallback
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s

Implements mobile Nostr identity management per issue #29.

Android — NIP-55 Amber integration:
- Opens com.greenart7c3.nostrsigner via `nostrsigner:` URI scheme to
  retrieve the user's public key without exposing it to the app.
- Listens for the `mobile://nostr-callback` deep link response and stores
  the resulting npub in Expo SecureStore.
- Falls back to Play Store install prompt when Amber is not installed.

iOS / manual fallback:
- NostrConnectModal accepts an nsec1 paste-in, validates bech32, derives
  the pubkey via nostr-tools getPublicKey, and stores the key only in
  Expo SecureStore — never in AsyncStorage, Redux, or logs.

Both platforms:
- Truncated npub and signer type (Amber / nsec) shown in Settings.
- "Disconnect Nostr" wipes all keys from SecureStore and resets state.
- Identity persists across restarts via SecureStore.

Supporting changes:
- NostrContext: new React context for identity lifecycle.
- NostrConnectModal: platform-aware bottom-sheet modal for connect flow.
- TimmyContext: added apiBaseUrl/setApiBaseUrl/isConnected; URL persisted
  in AsyncStorage and restored on mount; circular dep broken via refs.
- constants/colors: added field, textInverted, destructive, link colours.
- constants/storage-keys: added SERVER_URL_KEY.
- app.json: added Android intent filter for mobile://nostr-callback.
- package.json: added nostr-tools and expo-secure-store dependencies.

Fixes #29

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-23 22:30:40 -04:00
parent 94d2e48455
commit 88d3c6d9d0
9 changed files with 831 additions and 166 deletions

View File

@@ -16,6 +16,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { TimmyProvider } from "@/context/TimmyContext";
import { NostrProvider } from "@/context/NostrContext";
import { ONBOARDING_COMPLETED_KEY } from "@/constants/storage-keys";
SplashScreen.preventAutoHideAsync();
@@ -77,9 +78,11 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<TimmyProvider>
<RootLayoutNav />
</TimmyProvider>
<NostrProvider>
<TimmyProvider>
<RootLayoutNav />
</TimmyProvider>
</NostrProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</QueryClientProvider>

View File

@@ -1,114 +1,101 @@
import { Stack } from 'expo-router';
import { View, Text, StyleSheet, ScrollView, TextInput, Switch, Pressable, Linking, Platform } from 'react-native';
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
import { useTimmy } from '@/context/TimmyContext';
import { Ionicons } from '@expo/vector-icons';
import { ConnectionBadge } from '@/components/ConnectionBadge';
import { Colors } from '@/constants/colors';
import { Stack } from "expo-router";
import {
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { useState, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
const STORAGE_KEYS = {
SERVER_URL: 'settings_server_url',
NOTIFICATIONS_JOB_COMPLETION: 'settings_notifications_job_completion',
NOTIFICATIONS_LOW_BALANCE: 'settings_notifications_low_balance',
NOSTR_PRIVATE_KEY: 'settings_nostr_private_key', // Use SecureStore for this
};
import { useTimmy } from "@/context/TimmyContext";
import { useNostr, truncateNpub } from "@/context/NostrContext";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { NostrConnectModal } from "@/components/NostrConnectModal";
import { Colors } from "@/constants/colors";
const NOTIF_JOB_KEY = "settings.notifications_job_completion";
const NOTIF_BALANCE_KEY = "settings.notifications_low_balance";
export default function SettingsScreen() {
const { apiBaseUrl, setApiBaseUrl, isConnected, nostrPublicKey, connectNostr, disconnectNostr } = useTimmy();
const C = Colors.dark;
const { apiBaseUrl, setApiBaseUrl, isConnected } = useTimmy();
const { npub, nostrConnected, signerType, disconnect: disconnectNostr } = useNostr();
const [serverUrl, setServerUrl] = useState(apiBaseUrl);
const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false);
const [lowBalanceWarning, setLowBalanceWarning] = useState(false);
const [currentNpub, setCurrentNpub] = useState<string | null>(nostrPublicKey);
const [nostrModalVisible, setNostrModalVisible] = useState(false);
// Sync local serverUrl with context value (e.g. on first load from AsyncStorage)
useEffect(() => {
setServerUrl(apiBaseUrl);
}, [apiBaseUrl]);
useEffect(() => {
// Load settings from AsyncStorage and SecureStore
const loadSettings = async () => {
const storedServerUrl = await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL);
if (storedServerUrl) {
setServerUrl(storedServerUrl);
}
const storedJobCompletion = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION);
if (storedJobCompletion !== null) {
setJobCompletionNotifications(JSON.parse(storedJobCompletion));
}
const storedLowBalance = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE);
if (storedLowBalance !== null) {
setLowBalanceWarning(JSON.parse(storedLowBalance));
}
// Nostr npub is handled by TimmyContext, so we just use the provided nostrPublicKey
setCurrentNpub(nostrPublicKey);
};
loadSettings();
}, [nostrPublicKey]);
AsyncStorage.multiGet([NOTIF_JOB_KEY, NOTIF_BALANCE_KEY])
.then(([[, job], [, balance]]) => {
if (job !== null) setJobCompletionNotifications(JSON.parse(job));
if (balance !== null) setLowBalanceWarning(JSON.parse(balance));
})
.catch(() => {});
}, []);
// Update apiBaseUrl in context when serverUrl changes and is saved
useEffect(() => {
const handleServerUrlBlur = () => {
if (serverUrl !== apiBaseUrl) {
setApiBaseUrl(serverUrl);
AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl);
}
}, [serverUrl, setApiBaseUrl, apiBaseUrl]);
const handleServerUrlChange = (text: string) => {
setServerUrl(text);
};
const toggleJobCompletionNotifications = async () => {
const newValue = !jobCompletionNotifications;
setJobCompletionNotifications(newValue);
await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION, JSON.stringify(newValue));
const toggleJobCompletion = async () => {
const next = !jobCompletionNotifications;
setJobCompletionNotifications(next);
await AsyncStorage.setItem(NOTIF_JOB_KEY, JSON.stringify(next));
};
const toggleLowBalanceWarning = async () => {
const newValue = !lowBalanceWarning;
setLowBalanceWarning(newValue);
await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE, JSON.stringify(newValue));
};
const handleConnectNostr = async () => {
// This will ideally link to a dedicated Nostr connection flow
console.log('Connect Nostr button pressed');
// For now, simulate connection if not connected
if (!currentNpub) {
// This is a placeholder. Real implementation would involve generating/importing keys.
const simulatedNpub = 'npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
connectNostr(simulatedNpub, 'private_key_placeholder'); // Pass a placeholder private key
setCurrentNpub(simulatedNpub);
// In a real app, the private key would be securely stored and managed by the context
// For now, just a placeholder to show connected state
}
const toggleLowBalance = async () => {
const next = !lowBalanceWarning;
setLowBalanceWarning(next);
await AsyncStorage.setItem(NOTIF_BALANCE_KEY, JSON.stringify(next));
};
const handleDisconnectNostr = async () => {
await disconnectNostr();
setCurrentNpub(null);
};
const appVersion = Constants.expoConfig?.version || 'N/A';
const buildCommitHash = Constants.expoConfig?.extra?.gitCommitHash || 'N/A';
const giteaRepoUrl = 'http://143.198.27.163:3000/replit/timmy-tower';
const openGiteaLink = () => {
Linking.openURL(giteaRepoUrl);
};
const appVersion = Constants.expoConfig?.version ?? "N/A";
const buildCommitHash = Constants.expoConfig?.extra?.["gitCommitHash"] ?? "N/A";
const giteaRepoUrl = "http://143.198.27.163:3000/replit/timmy-tower";
return (
<View style={styles.container}>
<Stack.Screen options={{ title: 'Settings', headerShown: true, headerStyle: { backgroundColor: C.surface }, headerTintColor: C.text }} />
<View style={[styles.container, { backgroundColor: C.background }]}>
<Stack.Screen
options={{
title: "Settings",
headerShown: true,
headerStyle: { backgroundColor: C.surface },
headerTintColor: C.text,
}}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Text style={styles.sectionHeader}>Connection</Text>
{/* ── Connection ──────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Connection</Text>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>Server URL</Text>
<Text style={[styles.settingLabel, { color: C.text }]}>Server URL</Text>
<View style={styles.serverUrlContainer}>
<TextInput
style={[styles.input, { color: C.text, backgroundColor: C.field }]} // Apply text and background color from Colors
style={[styles.input, { color: C.text, backgroundColor: C.field, borderColor: C.border }]}
value={serverUrl}
onChangeText={handleServerUrlChange}
onChangeText={setServerUrl}
onBlur={handleServerUrlBlur}
placeholder="Enter server URL"
placeholderTextColor={C.textMuted}
autoCapitalize="none"
@@ -118,61 +105,100 @@ export default function SettingsScreen() {
</View>
</View>
<Text style={styles.sectionHeader}>Notifications</Text>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>Job Completion Push Notifications</Text>
{/* ── Notifications ────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Notifications</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Job Completion</Text>
<Switch
trackColor={{ false: C.surface, true: C.accentGlow }}
thumbColor={Platform.OS === 'android' ? C.text : ''}
thumbColor={Platform.OS === "android" ? C.text : ""}
ios_backgroundColor={C.field}
onValueChange={toggleJobCompletionNotifications}
onValueChange={toggleJobCompletion}
value={jobCompletionNotifications}
/>
</View>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>Low Balance Warning</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Low Balance Warning</Text>
<Switch
trackColor={{ false: C.surface, true: C.accentGlow }}
thumbColor={Platform.OS === 'android' ? C.text : ''}
thumbColor={Platform.OS === "android" ? C.text : ""}
ios_backgroundColor={C.field}
onValueChange={toggleLowBalanceWarning}
onValueChange={toggleLowBalance}
value={lowBalanceWarning}
/>
</View>
<Text style={styles.sectionHeader}>Identity</Text>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>Nostr Public Key</Text>
{/* ── Nostr Identity ───────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Identity</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Nostr Public Key</Text>
<Text style={[styles.settingValue, { color: C.textMuted }]}>
{currentNpub ? `${currentNpub.substring(0, 10)}...${currentNpub.substring(currentNpub.length - 5)}` : 'Not connected'}
{npub ? truncateNpub(npub) : "Not connected"}
</Text>
</View>
{nostrConnected && signerType && (
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Signer</Text>
<Text style={[styles.settingValue, { color: C.textSecondary }]}>
{signerType === "amber" ? "Amber (NIP-55)" : "nsec key"}
</Text>
</View>
)}
<View style={styles.buttonContainer}>
{!currentNpub ? (
<Pressable onPress={handleConnectNostr} style={({ pressed }) => [styles.button, { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }]}>
<Text style={[styles.buttonText, { color: C.textInverted }]}>Connect Nostr</Text>
{!nostrConnected ? (
<Pressable
onPress={() => setNostrModalVisible(true)}
style={({ pressed }) => [
styles.button,
{ backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 },
]}
>
<Text style={[styles.buttonText, { color: C.textInverted }]}>
Connect Nostr Identity
</Text>
</Pressable>
) : (
<Pressable onPress={handleDisconnectNostr} style={({ pressed }) => [styles.button, { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }]}>
<Text style={[styles.buttonText, { color: C.textInverted }]}>Disconnect Nostr</Text>
<Pressable
onPress={handleDisconnectNostr}
style={({ pressed }) => [
styles.button,
{ backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 },
]}
>
<Text style={[styles.buttonText, { color: C.textInverted }]}>
Disconnect Nostr
</Text>
</Pressable>
)}
</View>
<Text style={styles.sectionHeader}>About</Text>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>App Version</Text>
{/* ── About ───────────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>About</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>App Version</Text>
<Text style={[styles.settingValue, { color: C.text }]}>{appVersion}</Text>
</View>
<View style={styles.settingItem}>
<Text style={styles.settingLabel}>Build Commit Hash</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Build Commit</Text>
<Text style={[styles.settingValue, { color: C.text }]}>{buildCommitHash}</Text>
</View>
<Pressable onPress={openGiteaLink} style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}>
<Pressable
onPress={() => Linking.openURL(giteaRepoUrl)}
style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}
>
<Ionicons name="link" size={16} color={C.text} />
<Text style={[styles.linkButtonText, { color: C.link }]}>View project on Gitea</Text>
<Text style={[styles.linkButtonText, { color: C.link }]}>
View project on Gitea
</Text>
</Pressable>
</ScrollView>
<NostrConnectModal
visible={nostrModalVisible}
onClose={() => setNostrModalVisible(false)}
/>
</View>
);
}
@@ -180,7 +206,6 @@ export default function SettingsScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.dark.background, // Use background color from Colors
},
scrollContent: {
padding: 20,
@@ -188,36 +213,35 @@ const styles = StyleSheet.create({
},
sectionHeader: {
fontSize: 18,
fontWeight: 'bold',
color: Colors.dark.text,
fontWeight: "bold",
marginTop: 20,
marginBottom: 10,
},
settingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 0.5,
borderBottomColor: Colors.dark.border,
},
settingLabel: {
fontSize: 16,
color: Colors.dark.text,
flex: 1,
},
settingValue: {
fontSize: 16,
fontSize: 14,
flexShrink: 1,
textAlign: "right",
marginLeft: 8,
},
serverUrlContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
flex: 2,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: Colors.dark.border,
borderRadius: 8,
padding: 8,
fontSize: 14,
@@ -225,7 +249,7 @@ const styles = StyleSheet.create({
},
buttonContainer: {
marginTop: 20,
alignItems: 'flex-start',
alignItems: "flex-start",
},
button: {
paddingVertical: 10,
@@ -234,11 +258,11 @@ const styles = StyleSheet.create({
},
buttonText: {
fontSize: 16,
fontWeight: 'bold',
fontWeight: "bold",
},
linkButton: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
marginTop: 15,
paddingVertical: 8,
},