nostr login fetch details and nip05

master
gitea 2 months ago
parent d68124d975
commit 120a04c69f

@ -6,10 +6,14 @@ class NostrRelay {
/// Whether the relay is currently connected. /// Whether the relay is currently connected.
bool isConnected; bool isConnected;
/// Whether the relay is enabled (should be used).
bool isEnabled;
/// Creates a [NostrRelay] instance. /// Creates a [NostrRelay] instance.
NostrRelay({ NostrRelay({
required this.url, required this.url,
this.isConnected = false, this.isConnected = false,
this.isEnabled = true,
}); });
/// Creates a [NostrRelay] from a URL string. /// Creates a [NostrRelay] from a URL string.
@ -19,7 +23,7 @@ class NostrRelay {
@override @override
String toString() { String toString() {
return 'NostrRelay(url: $url, connected: $isConnected)'; return 'NostrRelay(url: $url, connected: $isConnected, enabled: $isEnabled)';
} }
@override @override

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:nostr_tools/nostr_tools.dart'; import 'package:nostr_tools/nostr_tools.dart';
import 'package:http/http.dart' as http;
import 'models/nostr_keypair.dart'; import 'models/nostr_keypair.dart';
import 'models/nostr_event.dart'; import 'models/nostr_event.dart';
import 'models/nostr_relay.dart'; import 'models/nostr_relay.dart';
@ -66,6 +67,35 @@ class NostrService {
disconnectRelay(relayUrl); disconnectRelay(relayUrl);
} }
/// Enables or disables a relay.
///
/// [relayUrl] - The URL of the relay to enable/disable.
/// [enabled] - Whether the relay should be enabled.
void setRelayEnabled(String relayUrl, bool enabled) {
final relay = _relays.firstWhere(
(r) => r.url == relayUrl,
orElse: () => throw NostrException('Relay not found: $relayUrl'),
);
relay.isEnabled = enabled;
// If disabling, also disconnect
if (!enabled && relay.isConnected) {
disconnectRelay(relayUrl);
}
}
/// Toggles all relays enabled/disabled.
///
/// [enabled] - Whether all relays should be enabled.
void setAllRelaysEnabled(bool enabled) {
for (final relay in _relays) {
relay.isEnabled = enabled;
if (!enabled && relay.isConnected) {
disconnectRelay(relay.url);
}
}
}
/// Gets the list of configured relays. /// Gets the list of configured relays.
List<NostrRelay> getRelays() { List<NostrRelay> getRelays() {
return List.unmodifiable(_relays); return List.unmodifiable(_relays);
@ -80,6 +110,15 @@ class NostrService {
/// Throws [NostrException] if connection fails. /// Throws [NostrException] if connection fails.
Future<Stream<Map<String, dynamic>>> connectRelay(String relayUrl) async { Future<Stream<Map<String, dynamic>>> connectRelay(String relayUrl) async {
try { try {
// Check if relay is enabled
final relay = _relays.firstWhere(
(r) => r.url == relayUrl,
orElse: () => NostrRelay.fromUrl(relayUrl),
);
if (!relay.isEnabled) {
throw NostrException('Relay is disabled: $relayUrl');
}
if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) { if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) {
// Already connected // Already connected
return _messageControllers[relayUrl]!.stream; return _messageControllers[relayUrl]!.stream;
@ -95,11 +134,10 @@ class NostrService {
} }
_connections[relayUrl] = channel; _connections[relayUrl] = channel;
final controller = StreamController<Map<String, dynamic>>(); final controller = StreamController<Map<String, dynamic>>.broadcast();
_messageControllers[relayUrl] = controller; _messageControllers[relayUrl] = controller;
// Update relay status // Update relay status (relay already found above)
final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl));
relay.isConnected = true; relay.isConnected = true;
// Listen for messages // Listen for messages
@ -303,7 +341,8 @@ class NostrService {
/// Fetches profile from a specific relay. /// Fetches profile from a specific relay.
Future<NostrProfile?> _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async { Future<NostrProfile?> _fetchProfileFromRelay(String publicKey, String relayUrl, Duration timeout) async {
final channel = _connections[relayUrl]; final channel = _connections[relayUrl];
if (channel == null) { final messageController = _messageControllers[relayUrl];
if (channel == null || messageController == null) {
return null; return null;
} }
@ -312,7 +351,7 @@ class NostrService {
final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}'; final reqId = 'profile_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<NostrProfile?>(); final completer = Completer<NostrProfile?>();
final subscription = _messageControllers[relayUrl]?.stream.listen( final subscription = messageController.stream.listen(
(message) { (message) {
// Message format from connectRelay: // Message format from connectRelay:
// {'type': 'EVENT', 'subscription_id': <id>, 'data': <event_json>} // {'type': 'EVENT', 'subscription_id': <id>, 'data': <event_json>}
@ -413,6 +452,118 @@ class NostrService {
} }
} }
/// Fetches preferred relays from a NIP-05 identifier.
///
/// NIP-05 verification endpoint format: https://<domain>/.well-known/nostr.json?name=<local-part>
/// The response can include relay hints in the format:
/// {
/// "names": { "<local-part>": "<hex-pubkey>" },
/// "relays": { "<hex-pubkey>": ["wss://relay1.com", "wss://relay2.com"] }
/// }
///
/// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com').
/// [publicKey] - The public key (hex format) to match against relay hints.
///
/// Returns a list of preferred relay URLs, or empty list if none found.
///
/// Throws [NostrException] if fetch fails.
Future<List<String>> fetchPreferredRelaysFromNip05(
String nip05,
String publicKey,
) async {
try {
// Parse NIP-05 identifier (format: local-part@domain)
final parts = nip05.split('@');
if (parts.length != 2) {
throw NostrException('Invalid NIP-05 format: $nip05');
}
final localPart = parts[0];
final domain = parts[1];
// Construct the verification URL
final url = Uri.https(domain, '/.well-known/nostr.json', {'name': localPart});
// Fetch the NIP-05 verification data
final response = await http.get(url).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw NostrException('Timeout fetching NIP-05 data');
},
);
if (response.statusCode != 200) {
throw NostrException('Failed to fetch NIP-05 data: ${response.statusCode}');
}
// Parse the JSON response
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Extract relay hints for the public key
final relays = data['relays'] as Map<String, dynamic>?;
if (relays == null) {
return [];
}
// Find relays for the matching public key (case-insensitive)
final publicKeyLower = publicKey.toLowerCase();
for (final entry in relays.entries) {
final keyLower = entry.key.toLowerCase();
if (keyLower == publicKeyLower) {
final relayList = entry.value;
if (relayList is List) {
return relayList
.map((r) => r.toString())
.where((r) => r.isNotEmpty)
.toList();
}
}
}
return [];
} catch (e) {
if (e is NostrException) {
rethrow;
}
throw NostrException('Failed to fetch preferred relays from NIP-05: $e');
}
}
/// Loads preferred relays from NIP-05 if available and adds them to the relay list.
///
/// [nip05] - The NIP-05 identifier (e.g., 'user@domain.com').
/// [publicKey] - The public key (hex format) to match against relay hints.
///
/// Returns the number of relays added.
///
/// Throws [NostrException] if fetch fails.
Future<int> loadPreferredRelaysFromNip05(
String nip05,
String publicKey,
) async {
try {
final preferredRelays = await fetchPreferredRelaysFromNip05(nip05, publicKey);
int addedCount = 0;
for (final relayUrl in preferredRelays) {
try {
addRelay(relayUrl);
addedCount++;
} catch (e) {
// Skip invalid relay URLs
debugPrint('Warning: Invalid relay URL from NIP-05: $relayUrl');
}
}
return addedCount;
} catch (e) {
if (e is NostrException) {
rethrow;
}
throw NostrException('Failed to load preferred relays from NIP-05: $e');
}
}
/// Closes all connections and cleans up resources. /// Closes all connections and cleans up resources.
void dispose() { void dispose() {
for (final relayUrl in _connections.keys.toList()) { for (final relayUrl in _connections.keys.toList()) {

@ -196,6 +196,9 @@ class SessionService {
// Set as current user // Set as current user
_currentUser = user; _currentUser = user;
// Load preferred relays from NIP-05 if available
await _loadPreferredRelaysIfAvailable();
return user; return user;
} catch (e) { } catch (e) {
throw SessionException('Failed to login with Nostr: $e'); throw SessionException('Failed to login with Nostr: $e');
@ -360,5 +363,46 @@ class SessionService {
if (_currentUser == null) return null; if (_currentUser == null) return null;
return _userCacheDirs[_currentUser!.id]; return _userCacheDirs[_currentUser!.id];
} }
/// Loads preferred relays from NIP-05 if the current user has an active nip05.
///
/// This method checks if the logged-in user has a nip05 identifier and,
/// if so, fetches preferred relays from the NIP-05 verification endpoint
/// and adds them to the Nostr service relay list.
///
/// This is called automatically after Nostr login, but can also be called
/// manually to refresh preferred relays.
Future<void> loadPreferredRelaysIfAvailable() async {
await _loadPreferredRelaysIfAvailable();
}
/// Internal method to load preferred relays from NIP-05 if available.
Future<void> _loadPreferredRelaysIfAvailable() async {
if (_currentUser == null || _nostrService == null) {
return;
}
final profile = _currentUser!.nostrProfile;
if (profile == null || profile.nip05 == null || profile.nip05!.isEmpty) {
return;
}
try {
final nip05 = profile.nip05!;
final publicKey = _currentUser!.id; // User ID is the public key for Nostr users
final addedCount = await _nostrService!.loadPreferredRelaysFromNip05(
nip05,
publicKey,
);
if (addedCount > 0) {
debugPrint('Loaded $addedCount preferred relay(s) from NIP-05: $nip05');
}
} catch (e) {
// Log error but don't fail - offline-first behavior
debugPrint('Warning: Failed to load preferred relays from NIP-05: $e');
}
}
} }

@ -131,6 +131,7 @@ class AppRouter {
builder: (_) => SessionScreen( builder: (_) => SessionScreen(
sessionService: sessionService, sessionService: sessionService,
firebaseService: firebaseService, firebaseService: firebaseService,
nostrService: nostrService,
), ),
settings: settings, settings: settings,
); );

@ -92,6 +92,7 @@ class _MainNavigationScaffoldState extends State<MainNavigationScaffold> {
return SessionScreen( return SessionScreen(
sessionService: widget.sessionService, sessionService: widget.sessionService,
firebaseService: widget.firebaseService, firebaseService: widget.firebaseService,
nostrService: widget.nostrService,
onSessionChanged: _onSessionStateChanged, onSessionChanged: _onSessionStateChanged,
); );
case 4: case 4:

@ -118,6 +118,68 @@ class RelayManagementController extends ChangeNotifier {
} }
} }
/// Tests connectivity to a single relay.
///
/// [relayUrl] - The URL of the relay to test.
///
/// Returns true if connection successful, false otherwise.
Future<bool> testRelay(String relayUrl) async {
_error = null;
notifyListeners();
try {
final stream = await nostrService
.connectRelay(relayUrl)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
throw Exception('Connection timeout');
},
);
_loadRelays();
// Cancel the stream subscription to clean up
stream.listen(null).cancel();
return true;
} catch (e) {
// Connection failed - disconnect to mark as unhealthy
try {
nostrService.disconnectRelay(relayUrl);
_loadRelays();
} catch (_) {
// Ignore disconnect errors
}
return false;
}
}
/// Toggles a relay on/off (enables/disables it).
///
/// [relayUrl] - The URL of the relay to toggle.
void toggleRelay(String relayUrl) {
try {
_error = null;
final relay = _relays.firstWhere((r) => r.url == relayUrl);
nostrService.setRelayEnabled(relayUrl, !relay.isEnabled);
_loadRelays();
} catch (e) {
_error = 'Failed to toggle relay: $e';
notifyListeners();
}
}
/// Toggles all relays on/off.
void toggleAllRelays() {
try {
_error = null;
final allEnabled = _relays.every((r) => r.isEnabled);
nostrService.setAllRelaysEnabled(!allEnabled);
_loadRelays();
} catch (e) {
_error = 'Failed to toggle all relays: $e';
notifyListeners();
}
}
/// Checks health of all relays by attempting to connect. /// Checks health of all relays by attempting to connect.
/// ///
/// Updates relay connection status. /// Updates relay connection status.

@ -4,8 +4,7 @@ import '../../data/nostr/models/nostr_relay.dart';
/// Screen for managing Nostr relays. /// Screen for managing Nostr relays.
/// ///
/// Allows users to view, add, remove, and monitor relay health, /// Allows users to view, add, remove, test, and toggle relays.
/// and trigger manual syncs.
class RelayManagementScreen extends StatefulWidget { class RelayManagementScreen extends StatefulWidget {
/// Controller for managing relay state. /// Controller for managing relay state.
final RelayManagementController controller; final RelayManagementController controller;
@ -22,6 +21,7 @@ class RelayManagementScreen extends StatefulWidget {
class _RelayManagementScreenState extends State<RelayManagementScreen> { class _RelayManagementScreenState extends State<RelayManagementScreen> {
final TextEditingController _urlController = TextEditingController(); final TextEditingController _urlController = TextEditingController();
final Map<String, bool> _testingRelays = {};
@override @override
void dispose() { void dispose() {
@ -29,6 +29,31 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
super.dispose(); super.dispose();
} }
Future<void> _handleTestRelay(String relayUrl) async {
setState(() {
_testingRelays[relayUrl] = true;
});
final success = await widget.controller.testRelay(relayUrl);
setState(() {
_testingRelays[relayUrl] = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? 'Relay test successful'
: 'Relay test failed',
),
duration: const Duration(seconds: 2),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -65,12 +90,48 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
), ),
), ),
// Actions section // Top action buttons
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Test All and Toggle All buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: widget.controller.isCheckingHealth
? null
: widget.controller.checkRelayHealth,
icon: widget.controller.isCheckingHealth
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
label: const Text('Test All'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: widget.controller.relays.isEmpty
? null
: widget.controller.toggleAllRelays,
icon: const Icon(Icons.power_settings_new),
label: Text(
widget.controller.relays.isNotEmpty &&
widget.controller.relays.every((r) => r.isEnabled)
? 'Turn All Off'
: 'Turn All On',
),
),
),
],
),
const SizedBox(height: 16),
// Add relay input // Add relay input
Row( Row(
children: [ children: [
@ -79,9 +140,7 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
controller: _urlController, controller: _urlController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Relay URL', labelText: 'Relay URL',
hintText: widget.controller.relays.isNotEmpty hintText: 'wss://relay.example.com',
? widget.controller.relays.first.url
: 'wss://nostrum.satoshinakamoto.win',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@ -108,56 +167,6 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
), ),
], ],
), ),
const SizedBox(height: 16),
// Action buttons
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: widget.controller.isCheckingHealth
? null
: widget.controller.checkRelayHealth,
icon: widget.controller.isCheckingHealth
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.health_and_safety),
label: const Text('Check Health'),
),
if (widget.controller.syncEngine != null)
ElevatedButton.icon(
onPressed: widget.controller.isSyncing
? null
: () async {
final success = await widget.controller.triggerManualSync();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
success
? 'Sync triggered successfully'
: 'Sync failed: ${widget.controller.error ?? "Unknown error"}',
),
duration: const Duration(seconds: 2),
),
);
}
},
icon: widget.controller.isSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: const Text('Manual Sync'),
),
],
),
], ],
), ),
), ),
@ -199,8 +208,9 @@ class _RelayManagementScreenState extends State<RelayManagementScreen> {
final relay = widget.controller.relays[index]; final relay = widget.controller.relays[index];
return _RelayListItem( return _RelayListItem(
relay: relay, relay: relay,
onConnect: () => widget.controller.connectRelay(relay.url), isTesting: _testingRelays[relay.url] ?? false,
onDisconnect: () => widget.controller.disconnectRelay(relay.url), onTest: () => _handleTestRelay(relay.url),
onToggle: () => widget.controller.toggleRelay(relay.url),
onRemove: () { onRemove: () {
widget.controller.removeRelay(relay.url); widget.controller.removeRelay(relay.url);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -227,19 +237,23 @@ class _RelayListItem extends StatelessWidget {
/// The relay to display. /// The relay to display.
final NostrRelay relay; final NostrRelay relay;
/// Callback when connect is pressed. /// Whether the relay is currently being tested.
final VoidCallback onConnect; final bool isTesting;
/// Callback when test is pressed.
final VoidCallback onTest;
/// Callback when disconnect is pressed. /// Callback when toggle is pressed.
final VoidCallback onDisconnect; final VoidCallback onToggle;
/// Callback when remove is pressed. /// Callback when remove is pressed.
final VoidCallback onRemove; final VoidCallback onRemove;
const _RelayListItem({ const _RelayListItem({
required this.relay, required this.relay,
required this.onConnect, required this.isTesting,
required this.onDisconnect, required this.onTest,
required this.onToggle,
required this.onRemove, required this.onRemove,
}); });
@ -247,45 +261,107 @@ class _RelayListItem extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile( child: Padding(
leading: CircleAvatar( padding: const EdgeInsets.all(12),
backgroundColor: relay.isConnected ? Colors.green : Colors.grey, child: Column(
child: Icon( crossAxisAlignment: CrossAxisAlignment.start,
relay.isConnected ? Icons.check : Icons.close, children: [
color: Colors.white, // Relay URL and status
size: 20, Row(
children: [
// Status indicator
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: relay.isConnected
? Colors.green
: relay.isEnabled
? Colors.orange
: Colors.grey,
), ),
), ),
title: Text( const SizedBox(width: 8),
Expanded(
child: Text(
relay.url, relay.url,
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
), ),
subtitle: Text( ),
relay.isConnected ? 'Connected' : 'Disconnected', ],
),
const SizedBox(height: 8),
// Status text
Text(
relay.isConnected
? 'Connected'
: relay.isEnabled
? 'Enabled (not connected)'
: 'Disabled',
style: TextStyle( style: TextStyle(
color: relay.isConnected ? Colors.green : Colors.grey, fontSize: 12,
color: relay.isConnected
? Colors.green
: relay.isEnabled
? Colors.orange
: Colors.grey,
), ),
), ),
trailing: Row( const SizedBox(height: 12),
mainAxisSize: MainAxisSize.min, // Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( // Test button
icon: Icon( OutlinedButton.icon(
relay.isConnected ? Icons.link_off : Icons.link, onPressed: isTesting ? null : onTest,
color: relay.isConnected ? Colors.orange : Colors.green, icon: isTesting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check, size: 16),
label: const Text('Test'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
tooltip: relay.isConnected ? 'Disconnect' : 'Connect',
onPressed: relay.isConnected ? onDisconnect : onConnect,
), ),
const SizedBox(width: 8),
// Toggle switch
Row(
children: [
Text(
relay.isEnabled ? 'On' : 'Off',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 4),
Switch(
value: relay.isEnabled,
onChanged: (_) => onToggle(),
),
],
),
const SizedBox(width: 8),
// Remove button
IconButton( IconButton(
icon: const Icon(Icons.delete, color: Colors.red), icon: const Icon(Icons.delete, size: 20),
color: Colors.red,
tooltip: 'Remove', tooltip: 'Remove',
onPressed: onRemove, onPressed: onRemove,
), ),
], ],
), ),
],
),
), ),
); );
} }
} }

@ -1,17 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../data/session/session_service.dart'; import '../../data/session/session_service.dart';
import '../../data/firebase/firebase_service.dart'; import '../../data/firebase/firebase_service.dart';
import '../../data/nostr/nostr_service.dart';
/// Screen for user session management (login/logout). /// Screen for user session management (login/logout).
class SessionScreen extends StatefulWidget { class SessionScreen extends StatefulWidget {
final SessionService? sessionService; final SessionService? sessionService;
final FirebaseService? firebaseService; final FirebaseService? firebaseService;
final NostrService? nostrService;
final VoidCallback? onSessionChanged; final VoidCallback? onSessionChanged;
const SessionScreen({ const SessionScreen({
super.key, super.key,
this.sessionService, this.sessionService,
this.firebaseService, this.firebaseService,
this.nostrService,
this.onSessionChanged, this.onSessionChanged,
}); });
@ -342,6 +345,17 @@ class _SessionScreenState extends State<SessionScreen> {
const Divider(), const Divider(),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
// NIP-05 section
if (currentUser.nostrProfile?.nip05 != null &&
currentUser.nostrProfile!.nip05!.isNotEmpty)
_Nip05Section(
nip05: currentUser.nostrProfile!.nip05!,
publicKey: currentUser.id,
nostrService: widget.nostrService,
),
if (currentUser.nostrProfile?.nip05 != null &&
currentUser.nostrProfile!.nip05!.isNotEmpty)
const SizedBox(height: 12),
Text('User ID: ${currentUser.id.substring(0, currentUser.id.length > 32 ? 32 : currentUser.id.length)}${currentUser.id.length > 32 ? '...' : ''}'), Text('User ID: ${currentUser.id.substring(0, currentUser.id.length > 32 ? 32 : currentUser.id.length)}${currentUser.id.length > 32 ? '...' : ''}'),
Text('Username: ${currentUser.username}'), Text('Username: ${currentUser.username}'),
Text( Text(
@ -494,3 +508,183 @@ class _SessionScreenState extends State<SessionScreen> {
} }
} }
/// Widget for displaying NIP-05 information including domain and preferred relays.
class _Nip05Section extends StatefulWidget {
final String nip05;
final String publicKey;
final NostrService? nostrService;
const _Nip05Section({
required this.nip05,
required this.publicKey,
this.nostrService,
});
@override
State<_Nip05Section> createState() => _Nip05SectionState();
}
class _Nip05SectionState extends State<_Nip05Section> {
List<String> _preferredRelays = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadPreferredRelays();
}
Future<void> _loadPreferredRelays() async {
if (widget.nostrService == null) {
setState(() {
_error = 'Nostr service not available';
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final relays = await widget.nostrService!.fetchPreferredRelaysFromNip05(
widget.nip05,
widget.publicKey,
);
setState(() {
_preferredRelays = relays;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString().replaceAll('NostrException: ', '');
_isLoading = false;
});
}
}
String _getDomain() {
final parts = widget.nip05.split('@');
if (parts.length == 2) {
return parts[1];
}
return widget.nip05;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NIP-05',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.verified, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.nip05,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Domain: ${_getDomain()}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
const Text(
'Preferred Relays',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (_error != null)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 16, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
),
),
),
],
),
)
else if (_preferredRelays.isEmpty)
Text(
'No preferred relays found',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
)
else
..._preferredRelays.map((relay) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
relay,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
],
),
)),
],
);
}
}

@ -17,7 +17,6 @@ void main() {
databaseFactory = databaseFactoryFfi; databaseFactory = databaseFactoryFfi;
late MockLocalStorageService mockLocalStorage; late MockLocalStorageService mockLocalStorage;
late FirebaseService firebaseService;
late Directory tempDir; late Directory tempDir;
setUp(() async { setUp(() async {

@ -92,7 +92,8 @@ void main() {
expect(find.textContaining('wss://relay2.example.com'), findsWidgets); expect(find.textContaining('wss://relay2.example.com'), findsWidgets);
// Verify we have relay list items (Cards) // Verify we have relay list items (Cards)
expect(find.byType(Card), findsNWidgets(2)); expect(find.byType(Card), findsNWidgets(2));
expect(find.text('Disconnected'), findsNWidgets(2)); // New UI shows "Enabled (not connected)" or "Disabled" instead of "Disconnected"
expect(find.textContaining('Enabled'), findsWidgets);
}); });
testWidgets('adds relay when Add button is pressed', testWidgets('adds relay when Add button is pressed',
@ -157,16 +158,15 @@ void main() {
testWidgets('displays check health button', (WidgetTester tester) async { testWidgets('displays check health button', (WidgetTester tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
expect(find.text('Check Health'), findsOneWidget); expect(find.text('Test All'), findsOneWidget);
expect(find.byIcon(Icons.health_and_safety), findsOneWidget); expect(find.byIcon(Icons.network_check), findsOneWidget);
}); });
testWidgets('displays manual sync button when sync engine is configured', testWidgets('displays toggle all button', (WidgetTester tester) async {
(WidgetTester tester) async {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
expect(find.text('Manual Sync'), findsOneWidget); expect(find.text('Turn All On'), findsOneWidget);
expect(find.byIcon(Icons.sync), findsOneWidget); expect(find.byIcon(Icons.power_settings_new), findsOneWidget);
}); });
testWidgets('shows loading state during health check', testWidgets('shows loading state during health check',
@ -175,9 +175,9 @@ void main() {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pump(); await tester.pump();
// Tap check health button // Tap test all button
final healthButton = find.text('Check Health'); final testAllButton = find.text('Test All');
await tester.tap(healthButton); await tester.tap(testAllButton);
await tester.pump(); await tester.pump();
// Check for loading indicator (may be brief) // Check for loading indicator (may be brief)
@ -231,12 +231,14 @@ void main() {
await tester.pumpWidget(createTestWidget()); await tester.pumpWidget(createTestWidget());
await tester.pump(); await tester.pump();
// Verify relay URL is displayed (may appear in ListTile title) // Verify relay URL is displayed
expect(find.textContaining('wss://relay.example.com'), findsWidgets); expect(find.textContaining('wss://relay.example.com'), findsWidgets);
// Verify status indicator is present // Verify status indicator is present (now a Container with decoration, not CircleAvatar)
expect(find.byType(CircleAvatar), findsWidgets); // The status indicator is a Container with BoxDecoration, so we check for the Card instead
// Verify we have a relay card
expect(find.byType(Card), findsWidgets); expect(find.byType(Card), findsWidgets);
// Verify we have test button and toggle switch
expect(find.text('Test'), findsWidgets);
expect(find.byType(Switch), findsWidgets);
}); });
}); });
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.