Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
277 lines
8.3 KiB
TypeScript
277 lines
8.3 KiB
TypeScript
/**
|
|
* 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<string | null>(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 (
|
|
<Modal
|
|
visible={visible}
|
|
animationType="slide"
|
|
transparent
|
|
onRequestClose={handleClose}
|
|
>
|
|
<View style={styles.overlay}>
|
|
<View style={[styles.sheet, { backgroundColor: C.surface, borderColor: C.border }]}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text style={[styles.title, { color: C.text }]}>Connect Nostr Identity</Text>
|
|
<Pressable onPress={handleClose} hitSlop={12}>
|
|
<Ionicons name="close" size={22} color={C.textSecondary} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Android: Amber option */}
|
|
{canUseAmber && !showNsecForm && (
|
|
<View style={styles.body}>
|
|
<Text style={[styles.description, { color: C.textSecondary }]}>
|
|
Connect using{" "}
|
|
<Text style={{ color: C.text, fontWeight: "600" }}>Amber</Text>{" "}
|
|
— your keys stay in Amber and are never exposed to this app.
|
|
</Text>
|
|
|
|
<Pressable
|
|
onPress={handleAmberPress}
|
|
disabled={loading}
|
|
style={({ pressed }) => [
|
|
styles.primaryButton,
|
|
{ backgroundColor: C.accent, opacity: pressed || loading ? 0.75 : 1 },
|
|
]}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color={C.textInverted} />
|
|
) : (
|
|
<>
|
|
<Ionicons name="shield-checkmark" size={18} color={C.textInverted} />
|
|
<Text style={[styles.buttonText, { color: C.textInverted }]}>
|
|
Connect with Amber
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={() => setShowNsecForm(true)}
|
|
style={styles.secondaryLink}
|
|
>
|
|
<Text style={[styles.secondaryLinkText, { color: C.link }]}>
|
|
Enter nsec manually instead
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
{/* nsec form */}
|
|
{showNsecForm && (
|
|
<View style={styles.body}>
|
|
{canUseAmber && (
|
|
<Pressable
|
|
onPress={() => { setShowNsecForm(false); setError(null); }}
|
|
style={styles.backLink}
|
|
>
|
|
<Ionicons name="arrow-back" size={14} color={C.link} />
|
|
<Text style={[styles.secondaryLinkText, { color: C.link }]}>
|
|
Use Amber instead
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
|
|
<Text style={[styles.description, { color: C.textSecondary }]}>
|
|
Paste your{" "}
|
|
<Text style={{ color: C.text, fontWeight: "600" }}>nsec1…</Text>{" "}
|
|
private key. It will be stored only in the device secure keystore
|
|
and never logged or transmitted.
|
|
</Text>
|
|
|
|
<TextInput
|
|
style={[
|
|
styles.input,
|
|
{
|
|
backgroundColor: C.field,
|
|
color: C.text,
|
|
borderColor: error ? C.destructive : C.border,
|
|
},
|
|
]}
|
|
placeholder="nsec1…"
|
|
placeholderTextColor={C.textMuted}
|
|
value={nsecInput}
|
|
onChangeText={(t) => { setNsecInput(t); setError(null); }}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
secureTextEntry
|
|
editable={!loading}
|
|
/>
|
|
|
|
{error && (
|
|
<Text style={[styles.errorText, { color: C.destructive }]}>
|
|
{error}
|
|
</Text>
|
|
)}
|
|
|
|
<Pressable
|
|
onPress={handleNsecConnect}
|
|
disabled={loading}
|
|
style={({ pressed }) => [
|
|
styles.primaryButton,
|
|
{ backgroundColor: C.accent, opacity: pressed || loading ? 0.75 : 1 },
|
|
]}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator color={C.textInverted} />
|
|
) : (
|
|
<Text style={[styles.buttonText, { color: C.textInverted }]}>
|
|
Connect
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─── 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,
|
|
},
|
|
});
|