diff --git a/lib/data/nostr/nostr_service.dart b/lib/data/nostr/nostr_service.dart index 6617795..787a47d 100644 --- a/lib/data/nostr/nostr_service.dart +++ b/lib/data/nostr/nostr_service.dart @@ -238,7 +238,7 @@ class NostrService { } } - /// Publishes an event to all connected relays. + /// Publishes an event to all enabled relays. /// /// [event] - The Nostr event to publish. /// @@ -247,14 +247,36 @@ class NostrService { final results = {}; for (final relay in _relays) { - if (relay.isConnected) { + // Only publish to enabled relays + if (!relay.isEnabled) { + results[relay.url] = false; + continue; + } + + // Try to connect if not already connected + if (!relay.isConnected) { try { - await publishEvent(event, relay.url); - results[relay.url] = true; + final stream = await connectRelay(relay.url).timeout( + const Duration(seconds: 3), + onTimeout: () { + throw Exception('Connection timeout'); + }, + ); + // Start listening to establish connection, then cancel immediately + final subscription = stream.listen(null); + await Future.delayed(const Duration(milliseconds: 100)); + subscription.cancel(); } catch (e) { results[relay.url] = false; + continue; } - } else { + } + + // Publish to the relay + try { + await publishEvent(event, relay.url); + results[relay.url] = true; + } catch (e) { results[relay.url] = false; } } diff --git a/lib/data/session/models/user.dart b/lib/data/session/models/user.dart index 50f9ed9..642a32d 100644 --- a/lib/data/session/models/user.dart +++ b/lib/data/session/models/user.dart @@ -20,6 +20,10 @@ class User { /// Optional Nostr profile data (if logged in via Nostr). final NostrProfile? nostrProfile; + /// Optional Nostr private key (nsec) if logged in with nsec. + /// This is stored to enable event publishing. Only set if user logged in with nsec. + final String? nostrPrivateKey; + /// Creates a [User] instance. /// /// [id] - Unique identifier for the user. @@ -27,12 +31,14 @@ class User { /// [token] - Optional authentication token. /// [createdAt] - Session creation timestamp (defaults to current time). /// [nostrProfile] - Optional Nostr profile data. + /// [nostrPrivateKey] - Optional Nostr private key (nsec) for event publishing. User({ required this.id, required this.username, this.token, int? createdAt, this.nostrProfile, + this.nostrPrivateKey, }) : createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch; /// Creates a [User] from a Map (e.g., from database or JSON). @@ -45,6 +51,7 @@ class User { nostrProfile: map['nostr_profile'] != null ? NostrProfile.fromJson(map['nostr_profile'] as Map) : null, + nostrPrivateKey: map['nostr_private_key'] as String?, ); } @@ -56,6 +63,7 @@ class User { 'token': token, 'created_at': createdAt, 'nostr_profile': nostrProfile?.toJson(), + 'nostr_private_key': nostrPrivateKey, }; } @@ -66,6 +74,7 @@ class User { String? token, int? createdAt, NostrProfile? nostrProfile, + String? nostrPrivateKey, }) { return User( id: id ?? this.id, @@ -73,6 +82,7 @@ class User { token: token ?? this.token, createdAt: createdAt ?? this.createdAt, nostrProfile: nostrProfile ?? this.nostrProfile, + nostrPrivateKey: nostrPrivateKey ?? this.nostrPrivateKey, ); } diff --git a/lib/data/session/session_service.dart b/lib/data/session/session_service.dart index 994d09d..592cd94 100644 --- a/lib/data/session/session_service.dart +++ b/lib/data/session/session_service.dart @@ -156,10 +156,15 @@ class SessionService { try { // Parse the key NostrKeyPair keyPair; + String? storedPrivateKey; if (nsecOrNpub.startsWith('nsec')) { keyPair = NostrKeyPair.fromNsec(nsecOrNpub); + // Store the nsec for event publishing + storedPrivateKey = nsecOrNpub; } else if (nsecOrNpub.startsWith('npub')) { keyPair = NostrKeyPair.fromNpub(nsecOrNpub); + // No private key available when using npub + storedPrivateKey = null; } else { throw SessionException('Invalid Nostr key format. Expected nsec or npub.'); } @@ -173,11 +178,12 @@ class SessionService { // Continue without profile - offline-first behavior } - // Create user with Nostr profile + // Create user with Nostr profile and private key (if available) final user = User( id: keyPair.publicKey, username: profile?.displayName ?? keyPair.publicKey.substring(0, 16), nostrProfile: profile, + nostrPrivateKey: storedPrivateKey, ); // Create user-specific storage paths diff --git a/lib/ui/navigation/app_router.dart b/lib/ui/navigation/app_router.dart index 91a6dc7..f8c1b14 100644 --- a/lib/ui/navigation/app_router.dart +++ b/lib/ui/navigation/app_router.dart @@ -105,6 +105,7 @@ class AppRouter { builder: (_) => NostrEventsScreen( nostrService: nostrService, syncEngine: syncEngine, + sessionService: sessionService, ), settings: settings, ); diff --git a/lib/ui/navigation/main_navigation_scaffold.dart b/lib/ui/navigation/main_navigation_scaffold.dart index f17bf30..fb16553 100644 --- a/lib/ui/navigation/main_navigation_scaffold.dart +++ b/lib/ui/navigation/main_navigation_scaffold.dart @@ -87,6 +87,7 @@ class _MainNavigationScaffoldState extends State { return NostrEventsScreen( nostrService: widget.nostrService, syncEngine: widget.syncEngine, + sessionService: widget.sessionService, ); case 3: return SessionScreen( diff --git a/lib/ui/nostr_events/nostr_events_screen.dart b/lib/ui/nostr_events/nostr_events_screen.dart index 7e8cc15..81c509b 100644 --- a/lib/ui/nostr_events/nostr_events_screen.dart +++ b/lib/ui/nostr_events/nostr_events_screen.dart @@ -1,19 +1,23 @@ import 'package:flutter/material.dart'; import '../../data/nostr/nostr_service.dart'; +import '../../data/sync/sync_engine.dart'; +import '../../data/session/session_service.dart'; import '../../data/nostr/models/nostr_keypair.dart'; import '../../data/nostr/models/nostr_event.dart'; -import '../../data/nostr/models/nostr_relay.dart'; -import '../../data/sync/sync_engine.dart'; +import '../relay_management/relay_management_screen.dart'; +import '../relay_management/relay_management_controller.dart'; /// Screen for displaying and testing Nostr events. class NostrEventsScreen extends StatefulWidget { final NostrService? nostrService; final SyncEngine? syncEngine; + final SessionService? sessionService; const NostrEventsScreen({ super.key, this.nostrService, this.syncEngine, + this.sessionService, }); @override @@ -21,241 +25,43 @@ class NostrEventsScreen extends StatefulWidget { } class _NostrEventsScreenState extends State { - NostrKeyPair? _keyPair; - List _relays = []; - Map _connectionStatus = {}; List _events = []; bool _isLoading = false; - final TextEditingController _nsecController = TextEditingController(); - final TextEditingController _npubController = TextEditingController(); - final TextEditingController _hexPrivateKeyController = TextEditingController(); - bool _showImportFields = false; - - @override - void initState() { - super.initState(); - _loadRelays(); - _generateKeyPair(); - } - - @override - void dispose() { - _nsecController.dispose(); - _npubController.dispose(); - _hexPrivateKeyController.dispose(); - super.dispose(); - } - - void _loadRelays() { - if (widget.nostrService == null) return; - - setState(() { - _relays = widget.nostrService!.getRelays(); - _connectionStatus = { - for (var relay in _relays) relay.url: relay.isConnected - }; - }); - } - - void _generateKeyPair() { - if (widget.nostrService == null) return; - - setState(() { - _keyPair = widget.nostrService!.generateKeyPair(); - _nsecController.clear(); - _npubController.clear(); - _hexPrivateKeyController.clear(); - _showImportFields = false; - }); - } - - void _importFromNsec() { - final nsec = _nsecController.text.trim(); - if (nsec.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter an nsec key'), - backgroundColor: Colors.orange, - ), - ); - return; - } - - try { - setState(() { - _keyPair = NostrKeyPair.fromNsec(nsec); - _showImportFields = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Keypair imported from nsec'), - backgroundColor: Colors.green, - ), - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to import nsec: ${e.toString()}'), - backgroundColor: Colors.red, - ), - ); - } - } - - void _importFromNpub() { - final npub = _npubController.text.trim(); - if (npub.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter an npub key'), - backgroundColor: Colors.orange, - ), - ); - return; - } - - try { - setState(() { - _keyPair = NostrKeyPair.fromNpub(npub); - _showImportFields = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Public key imported from npub (note: cannot sign events without private key)'), - backgroundColor: Colors.orange, - ), - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to import npub: ${e.toString()}'), - backgroundColor: Colors.red, - ), - ); - } - } - - void _importFromHexPrivateKey() { - final hexKey = _hexPrivateKeyController.text.trim(); - if (hexKey.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter a hex private key'), - backgroundColor: Colors.orange, - ), - ); - return; - } - - try { - setState(() { - _keyPair = NostrKeyPair.fromHexPrivateKey(hexKey); - _showImportFields = false; - }); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Keypair imported from hex private key'), - backgroundColor: Colors.green, - ), - ); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to import hex key: ${e.toString()}'), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _connectToRelay(String relayUrl) async { - if (widget.nostrService == null) return; - - setState(() { - _isLoading = true; - }); - - try { - await widget.nostrService!.connectRelay(relayUrl).timeout( - const Duration(seconds: 5), - onTimeout: () { - throw Exception('Connection timeout'); - }, - ); - - setState(() { - _connectionStatus[relayUrl] = true; - _isLoading = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Connected to $relayUrl'), - backgroundColor: Colors.green, - ), - ); - } - - _loadRelays(); - } catch (e) { - setState(() { - _connectionStatus[relayUrl] = false; - _isLoading = false; - }); + Future _publishTestEvent() async { + if (widget.nostrService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to connect: ${e.toString()}'), + const SnackBar( + content: Text('Nostr service not available'), backgroundColor: Colors.red, ), ); } + return; } - } - - Future _disconnectFromRelay(String relayUrl) async { - if (widget.nostrService == null) return; - - widget.nostrService!.disconnectRelay(relayUrl); - - setState(() { - _connectionStatus[relayUrl] = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Disconnected from $relayUrl'), - ), - ); - } - - _loadRelays(); - } - Future _publishTestEvent() async { - if (widget.nostrService == null || _keyPair == null) { + // Check if user is logged in with Nostr + final currentUser = widget.sessionService?.currentUser; + if (currentUser == null || currentUser.nostrPrivateKey == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Nostr service or keypair not available'), - backgroundColor: Colors.red, + content: Text('Please log in with Nostr (nsec key) to publish events.'), + backgroundColor: Colors.orange, ), ); } return; } - if (_relays.isEmpty) { + // Get relays + final relays = widget.nostrService!.getRelays(); + if (relays.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('No relays configured. Add relays in Settings.'), + content: Text('No relays configured. Add relays in Relay Management.'), backgroundColor: Colors.orange, ), ); @@ -267,30 +73,18 @@ class _NostrEventsScreenState extends State { _isLoading = true; }); - if (_keyPair!.privateKey.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Cannot publish: No private key available. Import nsec or hex private key.'), - backgroundColor: Colors.red, - ), - ); - } - setState(() { - _isLoading = false; - }); - return; - } - try { + // Reconstruct keypair from stored nsec + final keyPair = NostrKeyPair.fromNsec(currentUser.nostrPrivateKey!); + // Create a test event final event = NostrEvent.create( content: 'Test event from Flutter app - ${DateTime.now().toIso8601String()}', kind: 1, // Text note - privateKey: _keyPair!.privateKey, + privateKey: keyPair.privateKey, ); - // Publish to all connected relays + // Publish to all enabled relays final results = await widget.nostrService!.publishEventToAllRelays(event); setState(() { @@ -330,16 +124,6 @@ class _NostrEventsScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('Nostr Events'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - _loadRelays(); - _generateKeyPair(); - }, - tooltip: 'Refresh', - ), - ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -348,10 +132,6 @@ class _NostrEventsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildKeyPairSection(), - const SizedBox(height: 24), - _buildRelaysSection(), - const SizedBox(height: 24), _buildActionsSection(), if (_events.isNotEmpty) ...[ const SizedBox(height: 24), @@ -363,201 +143,6 @@ class _NostrEventsScreenState extends State { ); } - Widget _buildKeyPairSection() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Keypair', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - TextButton.icon( - onPressed: () { - setState(() { - _showImportFields = !_showImportFields; - }); - }, - icon: Icon(_showImportFields ? Icons.visibility_off : Icons.import_export), - label: Text(_showImportFields ? 'Hide Import' : 'Import Key'), - ), - ], - ), - const SizedBox(height: 12), - if (_keyPair != null) ...[ - const Text( - 'Public Key:', - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - SelectableText( - _keyPair!.publicKey, - style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), - ), - const SizedBox(height: 8), - if (_keyPair!.privateKey.isNotEmpty) ...[ - const Text( - 'Private Key:', - style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), - ), - SelectableText( - _keyPair!.privateKey, - style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), - ), - ] else ...[ - const Text( - 'Private Key: (not available - imported from npub only)', - style: TextStyle(fontSize: 12, color: Colors.orange), - ), - ], - ] else ...[ - const Text('No keypair generated'), - ], - const SizedBox(height: 12), - if (_showImportFields) ...[ - const Divider(), - const SizedBox(height: 8), - const Text( - 'Import Key', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - TextField( - controller: _nsecController, - decoration: const InputDecoration( - labelText: 'nsec (private key)', - hintText: 'nsec1...', - border: OutlineInputBorder(), - helperText: 'Enter your nsec private key', - ), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _importFromNsec, - child: const Text('Import from nsec'), - ), - const SizedBox(height: 16), - TextField( - controller: _npubController, - decoration: const InputDecoration( - labelText: 'npub (public key)', - hintText: 'npub1...', - border: OutlineInputBorder(), - helperText: 'Enter npub to view only (cannot sign events)', - ), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _importFromNpub, - child: const Text('Import from npub'), - ), - const SizedBox(height: 16), - TextField( - controller: _hexPrivateKeyController, - decoration: const InputDecoration( - labelText: 'Hex Private Key', - hintText: '64 hex characters', - border: OutlineInputBorder(), - helperText: 'Enter private key in hex format (64 characters)', - ), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _importFromHexPrivateKey, - child: const Text('Import from Hex'), - ), - const Divider(), - const SizedBox(height: 8), - ], - ElevatedButton.icon( - onPressed: _generateKeyPair, - icon: const Icon(Icons.refresh), - label: const Text('Generate New Keypair'), - ), - ], - ), - ), - ); - } - - Widget _buildRelaysSection() { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Relays', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - if (_relays.isEmpty) - const Text( - 'No relays configured.\nAdd relays in Settings → Relay Management.', - style: TextStyle(color: Colors.grey), - ) - else - ..._relays.map((relay) => _buildRelayItem(relay)), - ], - ), - ), - ); - } - - Widget _buildRelayItem(NostrRelay relay) { - final isConnected = _connectionStatus[relay.url] ?? false; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - color: isConnected ? Colors.green.shade50 : Colors.grey.shade50, - child: ListTile( - title: Text( - relay.url, - style: const TextStyle(fontSize: 14, fontFamily: 'monospace'), - ), - subtitle: Text( - isConnected ? 'Connected' : 'Disconnected', - style: TextStyle( - color: isConnected ? Colors.green : Colors.grey, - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isConnected ? Icons.check_circle : Icons.cancel, - color: isConnected ? Colors.green : Colors.grey, - ), - const SizedBox(width: 8), - if (isConnected) - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => _disconnectFromRelay(relay.url), - tooltip: 'Disconnect', - ) - else - IconButton( - icon: const Icon(Icons.play_arrow, size: 20), - onPressed: () => _connectToRelay(relay.url), - tooltip: 'Connect', - ), - ], - ), - ), - ); - } - Widget _buildActionsSection() { return Card( child: Padding( @@ -584,13 +169,25 @@ class _NostrEventsScreenState extends State { const SizedBox(height: 8), TextButton.icon( onPressed: () { - // Navigate to Settings where Relay Management is accessible - // The bottom navigation bar will handle switching to Settings tab - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Go to Settings tab to manage relays'), - ), - ); + if (widget.nostrService != null && widget.syncEngine != null) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RelayManagementScreen( + controller: RelayManagementController( + nostrService: widget.nostrService!, + syncEngine: widget.syncEngine!, + ), + ), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nostr service not available'), + backgroundColor: Colors.red, + ), + ); + } }, icon: const Icon(Icons.settings), label: const Text('Manage Relays'), diff --git a/lib/ui/session/session_screen.dart b/lib/ui/session/session_screen.dart index cd92657..5f4d187 100644 --- a/lib/ui/session/session_screen.dart +++ b/lib/ui/session/session_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../data/session/session_service.dart'; import '../../data/firebase/firebase_service.dart'; import '../../data/nostr/nostr_service.dart'; +import '../../data/nostr/models/nostr_keypair.dart'; /// Screen for user session management (login/logout). class SessionScreen extends StatefulWidget { @@ -31,6 +32,7 @@ class _SessionScreenState extends State { bool _isLoading = false; bool _useFirebaseAuth = false; bool _useNostrLogin = false; + NostrKeyPair? _generatedKeyPair; @override void initState() { @@ -437,6 +439,115 @@ class _SessionScreenState extends State { ), const SizedBox(height: 24), if (_useNostrLogin) ...[ + // Key pair generation section + if (widget.nostrService != null) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Generate Key Pair', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: () { + setState(() { + _generatedKeyPair = widget.nostrService!.generateKeyPair(); + }); + }, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Generate'), + ), + ], + ), + if (_generatedKeyPair != null) ...[ + const SizedBox(height: 16), + // npub display + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'npub (Public Key):', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + SelectableText( + _generatedKeyPair!.toNpub(), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.content_copy), + tooltip: 'Copy to field', + onPressed: () { + _nostrKeyController.text = _generatedKeyPair!.toNpub(); + }, + ), + ], + ), + const SizedBox(height: 12), + // nsec display + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'nsec (Private Key):', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + SelectableText( + _generatedKeyPair!.toNsec(), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.content_copy), + tooltip: 'Copy to field', + onPressed: () { + _nostrKeyController.text = _generatedKeyPair!.toNsec(); + }, + ), + ], + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + ], TextField( controller: _nostrKeyController, decoration: const InputDecoration(