diff --git a/lib/presentation_layer/providers/messaging/nip65_relay_settings_provider.dart b/lib/presentation_layer/providers/messaging/nip65_relay_settings_provider.dart new file mode 100644 index 00000000..3bc1b582 --- /dev/null +++ b/lib/presentation_layer/providers/messaging/nip65_relay_settings_provider.dart @@ -0,0 +1,276 @@ +import 'dart:developer'; + +import 'package:camelus/domain_layer/entities/nip_65.dart'; +import 'package:camelus/domain_layer/usecases/inbox_outbox.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ndk/entities.dart' hide Nip65; + +import '../inbox_outbox_provider.dart'; +import '../ndk_provider.dart'; + +enum AddNip65RelayResult { success, invalidUrl, alreadyExists, saveFailed } + +class Nip65RelaySettingsState { + final Map relays; + final int? createdAt; + final bool isLoading; + final bool isSaving; + final String? error; + + const Nip65RelaySettingsState({ + this.relays = const {}, + this.createdAt, + this.isLoading = false, + this.isSaving = false, + this.error, + }); + + Nip65RelaySettingsState copyWith({ + Map? relays, + Object? createdAt = _keepValue, + bool? isLoading, + bool? isSaving, + Object? error = _keepValue, + }) { + return Nip65RelaySettingsState( + relays: relays ?? this.relays, + createdAt: createdAt == _keepValue ? this.createdAt : createdAt as int?, + isLoading: isLoading ?? this.isLoading, + isSaving: isSaving ?? this.isSaving, + error: error == _keepValue ? this.error : error as String?, + ); + } +} + +const _keepValue = Object(); + +enum _RelayRole { inbox, outbox } + +class Nip65RelaySettingsNotifier extends Notifier { + late final String? myPubkey; + + @override + Nip65RelaySettingsState build() { + final inboxOutbox = ref.watch(inboxOutboxProvider); + final pubkey = ref.watch(ndkProvider).accounts.getPublicKey(); + + _inboxOutbox = inboxOutbox; + myPubkey = pubkey; + + if (myPubkey != null) { + _loadFromCacheThenFetch(); + } + + return const Nip65RelaySettingsState(); + } + + late final InboxOutbox _inboxOutbox; + + Future _loadFromCacheThenFetch() async { + await fetchRelays(forceRefresh: false); + await fetchRelays(forceRefresh: true); + } + + Future fetchRelays({bool forceRefresh = true}) async { + if (myPubkey == null) { + return; + } + + if (state.relays.isEmpty) { + state = state.copyWith(isLoading: true, error: null); + } + + try { + final nip65 = await _inboxOutbox.getNip65data( + myPubkey!, + forceRefresh: forceRefresh, + ); + + if (nip65 == null) { + state = state.copyWith( + relays: {}, + createdAt: null, + isLoading: false, + error: null, + ); + return; + } + + state = state.copyWith( + relays: Map.from(nip65.relays), + createdAt: nip65.createdAt, + isLoading: false, + error: null, + ); + } catch (e) { + log('NIP-65: Error fetching relays: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future addInboxRelay(String relayUrl) { + return _addRelay(relayUrl, role: _RelayRole.inbox); + } + + Future addOutboxRelay(String relayUrl) { + return _addRelay(relayUrl, role: _RelayRole.outbox); + } + + Future _addRelay( + String relayUrl, { + required _RelayRole role, + }) async { + if (myPubkey == null) { + return AddNip65RelayResult.invalidUrl; + } + + final normalized = _normalizeRelayUrl(relayUrl); + if (normalized == null) { + return AddNip65RelayResult.invalidUrl; + } + + final currentMarker = state.relays[normalized]; + final hasRole = role == _RelayRole.inbox + ? (currentMarker?.isRead ?? false) + : (currentMarker?.isWrite ?? false); + + if (hasRole) { + return AddNip65RelayResult.alreadyExists; + } + + final isRead = role == _RelayRole.inbox || (currentMarker?.isRead ?? false); + final isWrite = + role == _RelayRole.outbox || (currentMarker?.isWrite ?? false); + + final updatedRelays = Map.from(state.relays) + ..[normalized] = _markerFrom(isRead: isRead, isWrite: isWrite); + + final saved = await _saveRelays(updatedRelays); + return saved ? AddNip65RelayResult.success : AddNip65RelayResult.saveFailed; + } + + Future removeInboxRelay(String relayUrl) { + return _removeRelay(relayUrl, role: _RelayRole.inbox); + } + + Future removeOutboxRelay(String relayUrl) { + return _removeRelay(relayUrl, role: _RelayRole.outbox); + } + + Future _removeRelay(String relayUrl, {required _RelayRole role}) async { + final currentMarker = state.relays[relayUrl]; + if (currentMarker == null) { + return true; + } + + final hasRole = role == _RelayRole.inbox + ? currentMarker.isRead + : currentMarker.isWrite; + + if (!hasRole) { + return true; + } + + final nextRead = role == _RelayRole.inbox ? false : currentMarker.isRead; + final nextWrite = role == _RelayRole.outbox ? false : currentMarker.isWrite; + + final updatedRelays = Map.from(state.relays); + if (!nextRead && !nextWrite) { + updatedRelays.remove(relayUrl); + } else { + updatedRelays[relayUrl] = _markerFrom( + isRead: nextRead, + isWrite: nextWrite, + ); + } + + return _saveRelays(updatedRelays); + } + + Future _saveRelays(Map relays) async { + if (myPubkey == null) { + return false; + } + + state = state.copyWith(isSaving: true, error: null); + + try { + final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + await _inboxOutbox.setNip65data( + Nip65(pubKey: myPubkey!, relays: relays, createdAt: createdAt), + ); + + final refreshed = await _inboxOutbox.getNip65data( + myPubkey!, + forceRefresh: true, + ); + + if (refreshed != null) { + state = state.copyWith( + relays: Map.from(refreshed.relays), + createdAt: refreshed.createdAt, + isSaving: false, + error: null, + ); + } else { + state = state.copyWith( + relays: relays, + createdAt: createdAt, + isSaving: false, + error: null, + ); + } + + return true; + } catch (e) { + log('NIP-65: Error saving relays: $e'); + state = state.copyWith(isSaving: false, error: e.toString()); + return false; + } + } + + String? _normalizeRelayUrl(String relayUrl) { + String url = relayUrl.trim(); + if (url.isEmpty) { + return null; + } + + if (!url.startsWith('wss://') && !url.startsWith('ws://')) { + url = 'wss://$url'; + } + + final uri = Uri.tryParse(url); + if (uri == null || + !uri.hasAuthority || + uri.host.isEmpty || + url.contains(' ')) { + return null; + } + + return url; + } + + ReadWriteMarker _markerFrom({required bool isRead, required bool isWrite}) { + if (isRead && isWrite) { + return ReadWriteMarker.readWrite; + } + + if (isRead) { + return ReadWriteMarker.readOnly; + } + + if (isWrite) { + return ReadWriteMarker.values.firstWhere( + (marker) => marker.isWrite && !marker.isRead, + orElse: () => ReadWriteMarker.readWrite, + ); + } + + return ReadWriteMarker.readWrite; + } +} + +final nip65RelaySettingsProvider = + NotifierProvider( + Nip65RelaySettingsNotifier.new, + ); diff --git a/lib/presentation_layer/routes/nostr/settings/nip65_relays/nip65_relays_settings.dart b/lib/presentation_layer/routes/nostr/settings/nip65_relays/nip65_relays_settings.dart new file mode 100644 index 00000000..2ac4f748 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/settings/nip65_relays/nip65_relays_settings.dart @@ -0,0 +1,383 @@ +import 'package:camelus/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; + +import '../../../../providers/messaging/nip65_relay_settings_provider.dart'; + +class Nip65RelaysSettings extends ConsumerStatefulWidget { + const Nip65RelaysSettings({super.key}); + + @override + ConsumerState createState() => + _Nip65RelaysSettingsState(); +} + +class _Nip65RelaysSettingsState extends ConsumerState { + final TextEditingController _inboxRelayController = TextEditingController(); + final TextEditingController _outboxRelayController = TextEditingController(); + String? _inboxErrorText; + String? _outboxErrorText; + + @override + void dispose() { + _inboxRelayController.dispose(); + _outboxRelayController.dispose(); + super.dispose(); + } + + Future _addInboxRelay() async { + final result = await ref + .read(nip65RelaySettingsProvider.notifier) + .addInboxRelay(_inboxRelayController.text); + _handleAddResult(result, isInbox: true); + } + + Future _addOutboxRelay() async { + final result = await ref + .read(nip65RelaySettingsProvider.notifier) + .addOutboxRelay(_outboxRelayController.text); + _handleAddResult(result, isInbox: false); + } + + void _handleAddResult(AddNip65RelayResult result, {required bool isInbox}) { + final l10n = AppLocalizations.of(context)!; + + switch (result) { + case AddNip65RelayResult.success: + if (isInbox) { + _inboxRelayController.clear(); + setState(() => _inboxErrorText = null); + } else { + _outboxRelayController.clear(); + setState(() => _outboxErrorText = null); + } + break; + case AddNip65RelayResult.invalidUrl: + setState(() { + if (isInbox) { + _inboxErrorText = l10n.invalidUrl; + } else { + _outboxErrorText = l10n.invalidUrl; + } + }); + break; + case AddNip65RelayResult.alreadyExists: + setState(() { + if (isInbox) { + _inboxErrorText = l10n.alreadyExists; + } else { + _outboxErrorText = l10n.alreadyExists; + } + }); + break; + case AddNip65RelayResult.saveFailed: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.failedToSaveRelays))); + break; + } + } + + void _clearInboxError() { + if (_inboxErrorText != null) { + setState(() => _inboxErrorText = null); + } + } + + void _clearOutboxError() { + if (_outboxErrorText != null) { + setState(() => _outboxErrorText = null); + } + } + + Future _removeInboxRelay(String relayUrl) async { + final success = await ref + .read(nip65RelaySettingsProvider.notifier) + .removeInboxRelay(relayUrl); + + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToSaveRelays), + ), + ); + } + } + + Future _removeOutboxRelay(String relayUrl) async { + final success = await ref + .read(nip65RelaySettingsProvider.notifier) + .removeOutboxRelay(relayUrl); + + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.failedToSaveRelays), + ), + ); + } + } + + String _formatCreatedAt(BuildContext context, int? createdAt) { + if (createdAt == null) { + return '—'; + } + + final dateTime = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ).toLocal(); + + final localizations = MaterialLocalizations.of(context); + final date = localizations.formatMediumDate(dateTime); + final time = localizations.formatTimeOfDay( + TimeOfDay.fromDateTime(dateTime), + alwaysUse24HourFormat: MediaQuery.of(context).alwaysUse24HourFormat, + ); + return '$date $time'; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(nip65RelaySettingsProvider); + + final inboxRelays = + state.relays.entries + .where((entry) => entry.value.isRead) + .map((entry) => entry.key) + .toList() + ..sort(); + + final outboxRelays = + state.relays.entries + .where((entry) => entry.value.isWrite) + .map((entry) => entry.key) + .toList() + ..sort(); + + return Scaffold( + appBar: AppBar( + scrolledUnderElevation: 0, + backgroundColor: Theme.of(context).colorScheme.surface, + leading: IconButton( + icon: Icon(PhosphorIcons.arrowLeft()), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text('${AppLocalizations.of(context)!.relays} (NIP-65)'), + actions: [ + if (state.isLoading || state.isSaving) + const Padding( + padding: EdgeInsets.only(right: 16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + IconButton( + icon: Icon(PhosphorIcons.arrowClockwise()), + onPressed: state.isSaving + ? null + : () => ref + .read(nip65RelaySettingsProvider.notifier) + .fetchRelays(), + tooltip: AppLocalizations.of(context)!.refresh, + ), + ], + ), + body: Column( + children: [ + Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + PhosphorIcons.info(), + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'NIP-65 relay hints for your account. Inbox relays are read relays, outbox relays are write relays. Changes are saved immediately.', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 13, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Last sync: ${_formatCreatedAt(context, state.createdAt)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(height: 12), + const Divider(), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + _buildRelaySection( + context: context, + title: 'Inbox relays', + relays: inboxRelays, + controller: _inboxRelayController, + errorText: _inboxErrorText, + isSaving: state.isSaving, + onChanged: _clearInboxError, + onSubmitted: _addInboxRelay, + onAdd: _addInboxRelay, + onRemove: _removeInboxRelay, + ), + const SizedBox(height: 20), + _buildRelaySection( + context: context, + title: 'Outbox relays', + relays: outboxRelays, + controller: _outboxRelayController, + errorText: _outboxErrorText, + isSaving: state.isSaving, + onChanged: _clearOutboxError, + onSubmitted: _addOutboxRelay, + onAdd: _addOutboxRelay, + onRemove: _removeOutboxRelay, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRelaySection({ + required BuildContext context, + required String title, + required List relays, + required TextEditingController controller, + required String? errorText, + required bool isSaving, + required VoidCallback onChanged, + required Future Function() onSubmitted, + required Future Function() onAdd, + required Future Function(String relayUrl) onRemove, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'wss://relay.example.com', + errorText: errorText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (_) => onChanged(), + onSubmitted: (_) => onSubmitted(), + ), + ), + const SizedBox(width: 12), + IconButton.filled( + onPressed: isSaving ? null : onAdd, + icon: Icon(PhosphorIcons.plus()), + tooltip: AppLocalizations.of(context)!.addRelay, + ), + ], + ), + const SizedBox(height: 12), + if (relays.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Text( + 'No relays configured', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + else + ...relays.map( + (relay) => _buildRelayTile(context, relay, isSaving, onRemove), + ), + ], + ); + } + + Widget _buildRelayTile( + BuildContext context, + String relay, + bool isSaving, + Future Function(String relayUrl) onRemove, + ) { + final displayUrl = relay + .replaceAll('wss://', '') + .replaceAll('ws://', '') + .replaceAll('/', ''); + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + PhosphorIcons.globe(), + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + title: Text( + displayUrl, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text( + relay, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + trailing: IconButton( + icon: Icon( + PhosphorIcons.trash(), + color: Theme.of(context).colorScheme.error, + ), + onPressed: isSaving ? null : () => onRemove(relay), + tooltip: AppLocalizations.of(context)!.removeRelay, + ), + ); + } +} diff --git a/lib/presentation_layer/routes/nostr/settings/settings_page.dart b/lib/presentation_layer/routes/nostr/settings/settings_page.dart index 9c057ef4..0f8e8901 100644 --- a/lib/presentation_layer/routes/nostr/settings/settings_page.dart +++ b/lib/presentation_layer/routes/nostr/settings/settings_page.dart @@ -62,6 +62,18 @@ class _SettingsPageState extends ConsumerState { context.push('/settings/notifications'); }, ), + ListTile( + title: Text('${AppLocalizations.of(context)!.relays} (NIP-65)'), + onTap: () { + context.push('/settings/nip65-relays'); + }, + ), + ListTile( + title: Text(AppLocalizations.of(context)!.dmRelays), + onTap: () { + context.push('/settings/dm-relays'); + }, + ), ListTile( title: Text(AppLocalizations.of(context)!.initialRoute), @@ -81,12 +93,7 @@ class _SettingsPageState extends ConsumerState { _navigateToFileServers(); }, ), - ListTile( - title: Text(AppLocalizations.of(context)!.dmRelays), - onTap: () { - context.push('/settings/dm-relays'); - }, - ), + ListTile( title: const Text('Developer settings'), onTap: () { diff --git a/lib/presentation_layer/routing/routes.dart b/lib/presentation_layer/routing/routes.dart index 8f414199..0487fc55 100644 --- a/lib/presentation_layer/routing/routes.dart +++ b/lib/presentation_layer/routing/routes.dart @@ -27,6 +27,7 @@ import '../routes/nostr/profile/profile_resolver_page.dart'; import '../routes/nostr/relays_page.dart'; import '../routes/nostr/search_feed_page/search_feed_page.dart'; import '../routes/nostr/settings/developer/developer_settings.dart'; +import '../routes/nostr/settings/nip65_relays/nip65_relays_settings.dart'; import '../routes/nostr/settings/notifications/notifications_settings.dart'; import '../routes/nostr/settings/theme/theme_settings.dart'; import '../routes/notification_page.dart'; @@ -167,6 +168,10 @@ final routes = [ path: 'dm-relays', builder: (context, state) => const DmRelaysSettings(), ), + GoRoute( + path: 'nip65-relays', + builder: (context, state) => const Nip65RelaysSettings(), + ), GoRoute( path: 'notifications', builder: (context, state) => const NotificationsSettingsPage(),