From 88d3c6d9d03292e1af4101359ecbc37fbc145e09 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 22:30:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20Nostr=20identity=20=E2=80=94=20?= =?UTF-8?q?Amber=20NIP-55=20deep=20link=20+=20nsec=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- artifacts/mobile/app.json | 15 +- artifacts/mobile/app/_layout.tsx | 9 +- artifacts/mobile/app/settings.tsx | 266 +++++++++-------- .../mobile/components/NostrConnectModal.tsx | 276 ++++++++++++++++++ artifacts/mobile/constants/colors.ts | 4 + artifacts/mobile/constants/storage-keys.ts | 1 + artifacts/mobile/context/NostrContext.tsx | 274 +++++++++++++++++ artifacts/mobile/context/TimmyContext.tsx | 150 +++++++--- artifacts/mobile/package.json | 2 + 9 files changed, 831 insertions(+), 166 deletions(-) create mode 100644 artifacts/mobile/components/NostrConnectModal.tsx create mode 100644 artifacts/mobile/context/NostrContext.tsx diff --git a/artifacts/mobile/app.json b/artifacts/mobile/app.json index e27cbea..7aaf9fc 100644 --- a/artifacts/mobile/app.json +++ b/artifacts/mobile/app.json @@ -20,7 +20,20 @@ "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png", "backgroundColor": "#0A0A12" - } + }, + "intentFilters": [ + { + "action": "VIEW", + "autoVerify": false, + "data": [ + { + "scheme": "mobile", + "host": "nostr-callback" + } + ], + "category": ["BROWSABLE", "DEFAULT"] + } + ] }, "web": { "favicon": "./assets/images/icon.png", diff --git a/artifacts/mobile/app/_layout.tsx b/artifacts/mobile/app/_layout.tsx index 4eb88fc..aa4c320 100644 --- a/artifacts/mobile/app/_layout.tsx +++ b/artifacts/mobile/app/_layout.tsx @@ -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() { - - - + + + + + diff --git a/artifacts/mobile/app/settings.tsx b/artifacts/mobile/app/settings.tsx index b0f38f5..c539719 100644 --- a/artifacts/mobile/app/settings.tsx +++ b/artifacts/mobile/app/settings.tsx @@ -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(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 ( - - + + - Connection + + {/* ── Connection ──────────────────────────────────────────────── */} + Connection - Server URL + Server URL - Notifications - - Job Completion Push Notifications + {/* ── Notifications ────────────────────────────────────────────── */} + Notifications + + Job Completion - - Low Balance Warning + + Low Balance Warning - Identity - - Nostr Public Key + {/* ── Nostr Identity ───────────────────────────────────────────── */} + Identity + + Nostr Public Key - {currentNpub ? `${currentNpub.substring(0, 10)}...${currentNpub.substring(currentNpub.length - 5)}` : 'Not connected'} + {npub ? truncateNpub(npub) : "Not connected"} + + {nostrConnected && signerType && ( + + Signer + + {signerType === "amber" ? "Amber (NIP-55)" : "nsec key"} + + + )} + - {!currentNpub ? ( - [styles.button, { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }]}> - Connect Nostr + {!nostrConnected ? ( + setNostrModalVisible(true)} + style={({ pressed }) => [ + styles.button, + { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }, + ]} + > + + Connect Nostr Identity + ) : ( - [styles.button, { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }]}> - Disconnect Nostr + [ + styles.button, + { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }, + ]} + > + + Disconnect Nostr + )} - About - - App Version + {/* ── About ───────────────────────────────────────────────────── */} + About + + App Version {appVersion} - - Build Commit Hash + + Build Commit {buildCommitHash} - [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}> + Linking.openURL(giteaRepoUrl)} + style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]} + > - View project on Gitea + + View project on Gitea + + + setNostrModalVisible(false)} + /> ); } @@ -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, }, diff --git a/artifacts/mobile/components/NostrConnectModal.tsx b/artifacts/mobile/components/NostrConnectModal.tsx new file mode 100644 index 0000000..6b45e3e --- /dev/null +++ b/artifacts/mobile/components/NostrConnectModal.tsx @@ -0,0 +1,276 @@ +/** + * NostrConnectModal — UI for connecting a Nostr identity on mobile. + * + * Android: offers "Connect with Amber" (NIP-55) as the primary action, + * with manual nsec entry as a secondary option. + * iOS / other: manual nsec entry only. + */ + +import React, { useCallback, useState } from "react"; +import { + ActivityIndicator, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; + +import { Colors } from "@/constants/colors"; +import { useNostr } from "@/context/NostrContext"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +type Props = { + visible: boolean; + onClose: () => void; +}; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function NostrConnectModal({ visible, onClose }: Props) { + const C = Colors.dark; + const { connectWithAmber, connectWithNsec, canUseAmber } = useNostr(); + + const [showNsecForm, setShowNsecForm] = useState(!canUseAmber); + const [nsecInput, setNsecInput] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleClose = useCallback(() => { + setNsecInput(""); + setError(null); + setShowNsecForm(!canUseAmber); + onClose(); + }, [canUseAmber, onClose]); + + const handleAmberPress = useCallback(async () => { + setError(null); + setLoading(true); + try { + await connectWithAmber(); + // Amber opens; the result arrives via deep-link callback. + // Close the modal — NostrContext handles the incoming URL. + handleClose(); + } finally { + setLoading(false); + } + }, [connectWithAmber, handleClose]); + + const handleNsecConnect = useCallback(async () => { + if (!nsecInput.trim()) { + setError("Please enter your nsec key"); + return; + } + setError(null); + setLoading(true); + const result = await connectWithNsec(nsecInput.trim()); + setLoading(false); + if (result.success) { + handleClose(); + } else { + setError(result.error); + } + }, [nsecInput, connectWithNsec, handleClose]); + + return ( + + + + {/* Header */} + + Connect Nostr Identity + + + + + + {/* Android: Amber option */} + {canUseAmber && !showNsecForm && ( + + + Connect using{" "} + Amber{" "} + — your keys stay in Amber and are never exposed to this app. + + + [ + styles.primaryButton, + { backgroundColor: C.accent, opacity: pressed || loading ? 0.75 : 1 }, + ]} + > + {loading ? ( + + ) : ( + <> + + + Connect with Amber + + + )} + + + setShowNsecForm(true)} + style={styles.secondaryLink} + > + + Enter nsec manually instead + + + + )} + + {/* nsec form */} + {showNsecForm && ( + + {canUseAmber && ( + { setShowNsecForm(false); setError(null); }} + style={styles.backLink} + > + + + Use Amber instead + + + )} + + + Paste your{" "} + nsec1…{" "} + private key. It will be stored only in the device secure keystore + and never logged or transmitted. + + + { setNsecInput(t); setError(null); }} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + editable={!loading} + /> + + {error && ( + + {error} + + )} + + [ + styles.primaryButton, + { backgroundColor: C.accent, opacity: pressed || loading ? 0.75 : 1 }, + ]} + > + {loading ? ( + + ) : ( + + Connect + + )} + + + )} + + + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + backgroundColor: "rgba(0,0,0,0.6)", + }, + sheet: { + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + borderWidth: 1, + borderBottomWidth: 0, + paddingHorizontal: 24, + paddingTop: 20, + paddingBottom: Platform.OS === "ios" ? 40 : 24, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 16, + }, + title: { + fontSize: 18, + fontWeight: "700", + }, + body: { + gap: 14, + }, + description: { + fontSize: 14, + lineHeight: 20, + }, + primaryButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + paddingVertical: 14, + borderRadius: 10, + }, + buttonText: { + fontSize: 16, + fontWeight: "600", + }, + secondaryLink: { + alignItems: "center", + paddingVertical: 4, + }, + secondaryLinkText: { + fontSize: 14, + }, + backLink: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 14, + fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", + }, + errorText: { + fontSize: 13, + }, +}); diff --git a/artifacts/mobile/constants/colors.ts b/artifacts/mobile/constants/colors.ts index f001bce..1d191a0 100644 --- a/artifacts/mobile/constants/colors.ts +++ b/artifacts/mobile/constants/colors.ts @@ -29,6 +29,10 @@ export const Colors = { working: "#F59E0B", idle: "#6B7280", micActive: "#EF4444", + field: "#1A1A2E", + textInverted: "#0A0A12", + destructive: "#EF4444", + link: "#A78BFA", }, } as const; diff --git a/artifacts/mobile/constants/storage-keys.ts b/artifacts/mobile/constants/storage-keys.ts index e07ff30..a6f0c60 100644 --- a/artifacts/mobile/constants/storage-keys.ts +++ b/artifacts/mobile/constants/storage-keys.ts @@ -1 +1,2 @@ export const ONBOARDING_COMPLETED_KEY = "app.onboarding_completed"; +export const SERVER_URL_KEY = "settings.server_url"; diff --git a/artifacts/mobile/context/NostrContext.tsx b/artifacts/mobile/context/NostrContext.tsx new file mode 100644 index 0000000..1d22ab2 --- /dev/null +++ b/artifacts/mobile/context/NostrContext.tsx @@ -0,0 +1,274 @@ +/** + * NostrContext — Nostr identity management for mobile. + * + * Android: NIP-55 Amber deep-link signing (com.greenart7c3.nostrsigner). + * Opens Amber via the `nostrsigner:` URI scheme to retrieve the user's + * public key; falls back to the Play Store install prompt when Amber is + * not installed. + * + * iOS / manual fallback: nsec paste-in stored exclusively in Expo SecureStore. + * The raw key is NEVER written to AsyncStorage, Redux state, or logs. + */ + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { Linking, Platform } from "react-native"; +import * as SecureStore from "expo-secure-store"; +import { getPublicKey, nip19 } from "nostr-tools"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type NostrSignerType = "amber" | "nsec" | null; + +export type NostrConnectResult = + | { success: true } + | { success: false; error: string }; + +type NostrContextValue = { + /** bech32 public key (npub1…), null when no identity is loaded */ + npub: string | null; + /** Raw hex public key, null when no identity is loaded */ + pubkeyHex: string | null; + /** How the key was connected */ + signerType: NostrSignerType; + /** True when an identity is loaded */ + nostrConnected: boolean; + /** True only on Android — Amber integration available */ + canUseAmber: boolean; + /** Android only: launch Amber to retrieve the user's public key */ + connectWithAmber: () => Promise; + /** Both platforms: validate & store an nsec; derive and cache the npub */ + connectWithNsec: (nsec: string) => Promise; + /** Wipe all Nostr credentials from SecureStore and reset state */ + disconnect: () => Promise; +}; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SECURE_KEY_NSEC = "nostr.nsec"; +const SECURE_KEY_NPUB = "nostr.npub"; +const SECURE_KEY_SIGNER_TYPE = "nostr.signer_type"; + +/** The deep-link scheme declared in app.json */ +const APP_SCHEME = "mobile"; +/** Path Amber will call back to with the pubkey result */ +const AMBER_CALLBACK_URL = `${APP_SCHEME}://nostr-callback`; +const AMBER_PACKAGE = "com.greenart7c3.nostrsigner"; +const AMBER_PLAY_STORE_URL = + "https://play.google.com/store/apps/details?id=com.greenart7c3.nostrsigner"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Truncate an npub for display: "npub1abcde…xyz12" */ +export function truncateNpub(npub: string): string { + if (npub.length <= 20) return npub; + return `${npub.substring(0, 10)}…${npub.substring(npub.length - 5)}`; +} + +// ─── Context ────────────────────────────────────────────────────────────────── + +const NostrContext = createContext(null); + +export function NostrProvider({ children }: { children: React.ReactNode }) { + const [npub, setNpub] = useState(null); + const [pubkeyHex, setPubkeyHex] = useState(null); + const [signerType, setSignerType] = useState(null); + + const canUseAmber = Platform.OS === "android"; + + // ── Load persisted identity on mount ────────────────────────────────────── + + useEffect(() => { + async function loadIdentity() { + try { + const [storedNpub, storedSignerType] = await Promise.all([ + SecureStore.getItemAsync(SECURE_KEY_NPUB), + SecureStore.getItemAsync(SECURE_KEY_SIGNER_TYPE), + ]); + + if (storedNpub && storedSignerType) { + setNpub(storedNpub); + setSignerType(storedSignerType as NostrSignerType); + try { + const decoded = nip19.decode(storedNpub); + if (decoded.type === "npub") { + setPubkeyHex(decoded.data as string); + } + } catch { + // npub decode failure — identity still "connected", pubkeyHex stays null + } + } + } catch { + // SecureStore unavailable (e.g. web build) — proceed without identity + } + } + loadIdentity(); + }, []); + + // ── Handle Amber callback deep link (Android) ───────────────────────────── + + useEffect(() => { + if (!canUseAmber) return; + + function handleUrl({ url }: { url: string }) { + if (!url.startsWith(`${APP_SCHEME}://nostr-callback`)) return; + + try { + // React Native's URL parsing is not available in all environments; + // parse manually to avoid importing a polyfill. + const queryStart = url.indexOf("?"); + if (queryStart === -1) return; + const params = new URLSearchParams(url.slice(queryStart + 1)); + const result = params.get("result"); + if (!result) return; + + // Amber returns the hex pubkey in `result` + let hexKey = result; + if (result.startsWith("npub1")) { + const decoded = nip19.decode(result); + if (decoded.type === "npub") hexKey = decoded.data as string; + } + + const derivedNpub = nip19.npubEncode(hexKey); + + // Persist — no private key stored for Amber flow + SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub).catch(() => {}); + SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "amber").catch(() => {}); + + setNpub(derivedNpub); + setPubkeyHex(hexKey); + setSignerType("amber"); + } catch { + // Malformed callback — silently ignore + } + } + + const subscription = Linking.addEventListener("url", handleUrl); + return () => subscription.remove(); + }, [canUseAmber]); + + // ── Actions ─────────────────────────────────────────────────────────────── + + const connectWithAmber = useCallback(async () => { + // NIP-55: request the user's public key from Amber + const amberUri = `nostrsigner:?type=get_public_key&compressionType=none&returnType=signature&callbackUrl=${encodeURIComponent(AMBER_CALLBACK_URL)}`; + + let canOpen = false; + try { + canOpen = await Linking.canOpenURL(`nostrsigner:`); + } catch { + canOpen = false; + } + + if (canOpen) { + await Linking.openURL(amberUri); + } else { + // Amber not installed — direct user to Play Store + await Linking.openURL(AMBER_PLAY_STORE_URL); + } + }, []); + + const connectWithNsec = useCallback( + async (nsec: string): Promise => { + const trimmed = nsec.trim(); + + if (!trimmed.startsWith("nsec1")) { + return { success: false, error: "Key must start with nsec1" }; + } + + let decoded: ReturnType; + try { + decoded = nip19.decode(trimmed); + } catch { + return { success: false, error: "Invalid bech32 encoding" }; + } + + if (decoded.type !== "nsec") { + return { success: false, error: "Not a valid nsec key" }; + } + + let hexPubkey: string; + try { + const sk = decoded.data as Uint8Array; + hexPubkey = getPublicKey(sk); + } catch { + return { success: false, error: "Could not derive public key" }; + } + + const derivedNpub = nip19.npubEncode(hexPubkey); + + try { + // Store only in SecureStore — never AsyncStorage, never logs + await SecureStore.setItemAsync(SECURE_KEY_NSEC, trimmed); + await SecureStore.setItemAsync(SECURE_KEY_NPUB, derivedNpub); + await SecureStore.setItemAsync(SECURE_KEY_SIGNER_TYPE, "nsec"); + } catch { + return { success: false, error: "Failed to store key securely" }; + } + + setNpub(derivedNpub); + setPubkeyHex(hexPubkey); + setSignerType("nsec"); + + return { success: true }; + }, + [] + ); + + const disconnect = useCallback(async () => { + try { + await Promise.all([ + SecureStore.deleteItemAsync(SECURE_KEY_NSEC), + SecureStore.deleteItemAsync(SECURE_KEY_NPUB), + SecureStore.deleteItemAsync(SECURE_KEY_SIGNER_TYPE), + ]); + } catch { + // Best-effort cleanup; reset state regardless + } + setNpub(null); + setPubkeyHex(null); + setSignerType(null); + }, []); + + // ── Context value ───────────────────────────────────────────────────────── + + const value = useMemo( + () => ({ + npub, + pubkeyHex, + signerType, + nostrConnected: npub !== null, + canUseAmber, + connectWithAmber, + connectWithNsec, + disconnect, + }), + [ + npub, + pubkeyHex, + signerType, + canUseAmber, + connectWithAmber, + connectWithNsec, + disconnect, + ] + ); + + return ( + {children} + ); +} + +export function useNostr(): NostrContextValue { + const ctx = useContext(NostrContext); + if (!ctx) throw new Error("useNostr must be used within NostrProvider"); + return ctx; +} + +export { AMBER_PACKAGE }; diff --git a/artifacts/mobile/context/TimmyContext.tsx b/artifacts/mobile/context/TimmyContext.tsx index 18a5a20..3f7de53 100644 --- a/artifacts/mobile/context/TimmyContext.tsx +++ b/artifacts/mobile/context/TimmyContext.tsx @@ -8,6 +8,9 @@ import React, { useState, } from "react"; import { AppState, Platform } from "react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +import { SERVER_URL_KEY } from "@/constants/storage-keys"; export type TimmyMood = "idle" | "thinking" | "working" | "speaking"; @@ -22,33 +25,42 @@ export type WsEvent = { count?: number; }; -export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting" | "error"; +export type ConnectionStatus = + | "connecting" + | "connected" + | "disconnected" + | "reconnecting" + | "error"; type TimmyContextValue = { timmyMood: TimmyMood; connectionStatus: ConnectionStatus; + /** True when the WebSocket is fully open */ + isConnected: boolean; recentEvents: WsEvent[]; send: (msg: object) => void; sendVisitorMessage: (text: string) => void; visitorId: string; + /** Current API / WebSocket base domain */ + apiBaseUrl: string; + /** Persist a new base URL and reconnect the WebSocket */ + setApiBaseUrl: (url: string) => void; }; const TimmyContext = createContext(null); const MAX_EVENTS = 100; -const BASE_URL = process.env.EXPO_PUBLIC_DOMAIN ?? ""; +const ENV_DOMAIN = process.env["EXPO_PUBLIC_DOMAIN"] ?? ""; const VISITOR_ID = Date.now().toString() + Math.random().toString(36).substr(2, 9); -function getWsUrl(): string { - let domain = BASE_URL; - if (!domain) { - domain = "localhost:8080"; - } - domain = domain.replace(/^https?:\/\//, ""); - domain = domain.replace(/\/$/, ""); - const proto = domain.startsWith("localhost") ? "ws" : "wss"; - return `${proto}://${domain}/api/ws`; +function buildWsUrl(domain: string): string { + let d = domain.trim(); + if (!d) d = "localhost:8080"; + d = d.replace(/^https?:\/\//, ""); + d = d.replace(/\/$/, ""); + const proto = d.startsWith("localhost") ? "ws" : "wss"; + return `${proto}://${d}/api/ws`; } function deriveMood(agentStates: Record): TimmyMood { @@ -63,10 +75,12 @@ function deriveMood(agentStates: Record): TimmyMood { } export function TimmyProvider({ children }: { children: React.ReactNode }) { + const [apiBaseUrl, setApiBaseUrlState] = useState(ENV_DOMAIN); const [timmyMood, setTimmyMood] = useState("idle"); const [connectionStatus, setConnectionStatus] = useState("connecting"); const [recentEvents, setRecentEvents] = useState([]); + const wsRef = useRef(null); const retryTimerRef = useRef | null>(null); const retryCountRef = useRef(0); @@ -77,6 +91,32 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { delta: "idle", }); const speakingTimerRef = useRef | null>(null); + // Stable ref so WebSocket callbacks always read the current URL + const apiBaseUrlRef = useRef(apiBaseUrl); + // Stable refs to break the connectWs ↔ scheduleRetry circular dependency + const connectWsRef = useRef<() => void>(() => {}); + const scheduleRetryRef = useRef<() => void>(() => {}); + + // ── Load persisted URL on mount ──────────────────────────────────────── + + useEffect(() => { + AsyncStorage.getItem(SERVER_URL_KEY) + .then((stored) => { + if (stored) { + setApiBaseUrlState(stored); + apiBaseUrlRef.current = stored; + } + }) + .catch(() => {}); + }, []); + + const setApiBaseUrl = useCallback((url: string) => { + setApiBaseUrlState(url); + apiBaseUrlRef.current = url; + AsyncStorage.setItem(SERVER_URL_KEY, url).catch(() => {}); + }, []); + + // ── WebSocket helpers ────────────────────────────────────────────────── const addEvent = useCallback((evt: Omit) => { const entry: WsEvent = { @@ -94,14 +134,14 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { wsRef.current.close(); wsRef.current = null; } - const url = getWsUrl(); + const url = buildWsUrl(apiBaseUrlRef.current); setConnectionStatus("connecting"); let ws: WebSocket; try { ws = new WebSocket(url); } catch { setConnectionStatus("error"); - scheduleRetry(); + scheduleRetryRef.current(); return; } wsRef.current = ws; @@ -134,10 +174,7 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { if (type === "world_state") { const states = (msg.agentStates as Record) ?? {}; - agentStatesRef.current = { - ...agentStatesRef.current, - ...states, - }; + agentStatesRef.current = { ...agentStatesRef.current, ...states }; setTimmyMood(deriveMood(agentStatesRef.current)); return; } @@ -187,7 +224,7 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { ws.onclose = () => { setConnectionStatus("disconnected"); - scheduleRetry(); + scheduleRetryRef.current(); }; ws.onerror = () => { @@ -200,9 +237,15 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000); retryCountRef.current += 1; retryTimerRef.current = setTimeout(() => { - connectWs(); + connectWsRef.current(); }, delay); - }, [connectWs]); + }, []); + + // Keep the stable refs current after every render + connectWsRef.current = connectWs; + scheduleRetryRef.current = scheduleRetry; + + // ── Initial connect ──────────────────────────────────────────────────── useEffect(() => { connectWs(); @@ -216,7 +259,19 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { }; }, [connectWs]); - // AppState-aware WebSocket reconnect on foreground + // Reconnect when apiBaseUrl changes (skip the very first render) + const isFirstRenderRef = useRef(true); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + retryCountRef.current = 0; + connectWs(); + }, [apiBaseUrl, connectWs]); + + // ── AppState-aware reconnect on foreground ───────────────────────────── + useEffect(() => { if (Platform.OS === "web") return; @@ -229,20 +284,17 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { const isNowActive = nextAppState === "active"; if (wasBackground && isNowActive) { - // App returned to foreground — check if WS is still alive const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) { - // Cancel any pending retry so we don't create duplicates if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } retryCountRef.current = 0; setConnectionStatus("reconnecting"); - connectWs(); + connectWsRef.current(); } } else if (nextAppState === "background") { - // Proactively close the WS to avoid OS killing it mid-frame if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; @@ -262,7 +314,9 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { return () => { subscription.remove(); }; - }, [connectWs]); + }, []); + + // ── Outbound messages ────────────────────────────────────────────────── const send = useCallback((msg: object) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { @@ -270,28 +324,42 @@ export function TimmyProvider({ children }: { children: React.ReactNode }) { } }, []); - const sendVisitorMessage = useCallback( - (text: string) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ type: "visitor_message", visitorId: VISITOR_ID, text }) - ); - setTimmyMood("thinking"); - } - }, - [] - ); + const sendVisitorMessage = useCallback((text: string) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "visitor_message", + visitorId: VISITOR_ID, + text, + }) + ); + setTimmyMood("thinking"); + } + }, []); - const value = useMemo( + // ── Context value ────────────────────────────────────────────────────── + + const value = useMemo( () => ({ + timmyMood, + connectionStatus, + isConnected: connectionStatus === "connected", + recentEvents, + send, + sendVisitorMessage, + visitorId: VISITOR_ID, + apiBaseUrl, + setApiBaseUrl, + }), + [ timmyMood, connectionStatus, recentEvents, send, sendVisitorMessage, - visitorId: VISITOR_ID, - }), - [timmyMood, connectionStatus, recentEvents, send, sendVisitorMessage] + apiBaseUrl, + setApiBaseUrl, + ] ); return ( diff --git a/artifacts/mobile/package.json b/artifacts/mobile/package.json index eb860e5..14bcaec 100644 --- a/artifacts/mobile/package.json +++ b/artifacts/mobile/package.json @@ -57,7 +57,9 @@ }, "dependencies": { "@react-native-voice/voice": "^3.2.4", + "expo-secure-store": "~14.0.1", "expo-speech": "^14.0.8", + "nostr-tools": "^2.23.3", "react-native-qrcode-svg": "^6.3.21", "react-native-webview": "^13.15.0" } -- 2.43.0