From df8b42683d8ce8abf94727624383136c37c0c363 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 16:59:26 -0300 Subject: [PATCH] Restart app to apply relay changes --- lib/main.dart | 6 +- lib/screens/profile_screen.dart | 574 +-------------------- lib/services/secure_storage_service.dart | 22 +- lib/services/updates_service.dart | 16 +- lib/utils/relay_utils.dart | 129 ----- lib/widgets/latest_releases_container.dart | 6 +- lib/widgets/relay_management_card.dart | 570 ++++++++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 9 files changed, 609 insertions(+), 720 deletions(-) delete mode 100644 lib/utils/relay_utils.dart create mode 100644 lib/widgets/relay_management_card.dart diff --git a/lib/main.dart b/lib/main.dart index 15ee050..112284d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -245,7 +246,10 @@ final appInitializationProvider = FutureProvider((ref) async { }); // AmberSigner provider for Nostr authentication -final amberSignerProvider = Provider(AmberSigner.new); +// Uses SecureStoragePubkeyPersistence to survive database clears +final amberSignerProvider = Provider( + (ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()), +); Future _attemptAutoSignIn(Ref ref) async { try { diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index edb590a..cbc2d78 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,48 +17,13 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/relay_utils.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; -import 'package:zapstore/widgets/latest_releases_container.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -// Note: Relay debugging features have been removed as they depend on internal APIs -// that are no longer public in purplebase 0.3.3+ - -/// Refresh token for app catalog relay list query. -/// Invalidate this provider to force a re-fetch of the relay list. -final _appCatalogRelaysRefreshProvider = StateProvider((ref) => 0); - -/// Provider for the user's app catalog relay list. -/// Watches the active pubkey and refresh token to enable invalidation. -final _appCatalogRelayListProvider = - Provider?>((ref) { - final pubkey = ref.watch(Signer.activePubkeyProvider); - final refreshToken = ref.watch(_appCatalogRelaysRefreshProvider); - - if (pubkey == null) { - return null; - } - - return ref.watch( - query( - authors: {pubkey}, - limit: 1, - source: const LocalAndRemoteSource( - relays: 'bootstrap', - stream: false, - ), - // Include refresh token to force new query after modifications - subscriptionPrefix: 'user-appcatalog-relays-$refreshToken', - ), - ); - }); +import 'package:zapstore/widgets/relay_management_card.dart'; /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { @@ -98,7 +63,7 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), // App Catalog Relay Management Section - const _AppCatalogRelayManagementSection(), + const RelayManagementCard(), const SizedBox(height: 16), @@ -1391,537 +1356,6 @@ class _EmptyState extends StatelessWidget { } } -/// App Catalog Relay Management Section - manages app catalog relays (kind 10067) -/// These are relays for discovering apps, NOT social relays like Damus/Primal. -class _AppCatalogRelayManagementSection extends HookConsumerWidget { - const _AppCatalogRelayManagementSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - - // Only show when user is signed in - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } - - final relayUrlController = useTextEditingController(); - final isOperating = useState(false); - final selectedProtocol = useState('wss://'); - final hasText = useState(false); - - // Listen to text changes to enable/disable add button - useEffect(() { - void listener() { - hasText.value = relayUrlController.text.trim().isNotEmpty; - } - - relayUrlController.addListener(listener); - return () => relayUrlController.removeListener(listener); - }, [relayUrlController]); - - // Watch the named provider (can be invalidated after save/publish) - final relayListState = ref.watch(_appCatalogRelayListProvider); - - final existingRelayList = relayListState?.models.firstOrNull; - final relays = (existingRelayList?.readRelays ?? {}).toList() - ..sort(); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - 'App Catalog Relays', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (isOperating.value) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const SizedBox(height: 8), - - // Info text - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'These relays are used to discover apps. Not for social content like Damus or Primal.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Current relays list - if (relays.isEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'Using default catalog relays. Add a relay to override.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.55), - ), - ), - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: SkeletonizerConfig( - data: AppColors.getSkeletonizerConfig( - Theme.of(context).brightness, - ), - child: Skeletonizer( - enabled: isOperating.value, - child: ListView.separated( - shrinkWrap: true, - physics: relays.length > 4 - ? const BouncingScrollPhysics() - : const NeverScrollableScrollPhysics(), - itemCount: relays.length, - separatorBuilder: (_, __) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final relayUrl = relays[index]; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 24, - child: Center( - child: Icon( - Icons.cloud, - size: 16, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - relayUrl, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - ), - ), - SizedBox( - width: 24, - height: 24, - child: IconButton( - icon: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.error, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 24, - height: 24, - ), - onPressed: isOperating.value - ? null - : () => _removeAppCatalogRelay( - context, - ref, - existingRelayList, - relayUrl, - isOperating, - ), - tooltip: 'Remove app catalog relay', - ), - ), - ], - ), - ); - }, - ), - ), - ), - ), - - const SizedBox(height: 8), - - // Add relay input - Row( - children: [ - Expanded( - child: TextField( - controller: relayUrlController, - enabled: !isOperating.value, - decoration: InputDecoration( - hintText: 'relay.example.com', - hintStyle: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - ), - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 8), - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(6), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedProtocol.value, - items: const [ - DropdownMenuItem( - value: 'wss://', - child: Text('wss://'), - ), - DropdownMenuItem( - value: 'ws://', - child: Text('ws://'), - ), - ], - onChanged: isOperating.value - ? null - : (value) { - if (value != null) { - selectedProtocol.value = value; - } - }, - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - isDense: true, - iconSize: 14, - ), - ), - ), - ), - ), - prefixIconConstraints: const BoxConstraints( - minWidth: 70, - minHeight: 0, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - isDense: true, - ), - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - onSubmitted: isOperating.value - ? null - : (value) { - if (value.trim().isNotEmpty) { - // Remove protocol if user typed it manually - final cleaned = value.trim().replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); - _addAppCatalogRelay( - context, - ref, - existingRelayList, - '${selectedProtocol.value}$cleaned', - relayUrlController, - isOperating, - ); - } - }, - ), - ), - const SizedBox(width: 8), - AsyncButtonBuilder( - onPressed: () async { - final url = relayUrlController.text.trim(); - if (url.isNotEmpty) { - // Remove protocol if user typed it manually - final cleaned = url.replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); - await _addAppCatalogRelay( - context, - ref, - existingRelayList, - '${selectedProtocol.value}$cleaned', - relayUrlController, - isOperating, - ); - } - }, - builder: (context, child, callback, buttonState) { - final isLoading = buttonState.maybeWhen( - loading: () => true, - orElse: () => false, - ); - final isDisabled = - isLoading || isOperating.value || !hasText.value; - return IconButton( - onPressed: isDisabled ? null : callback, - icon: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.add), - tooltip: 'Add relay', - style: IconButton.styleFrom( - backgroundColor: isDisabled - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3) - : Theme.of(context).colorScheme.primaryContainer, - foregroundColor: isDisabled - ? Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4) - : Theme.of(context).colorScheme.onPrimaryContainer, - ), - ); - }, - child: const SizedBox.shrink(), - ), - ], - ), - ], - ), - ), - ); - } - - Future _addAppCatalogRelay( - BuildContext context, - WidgetRef ref, - AppCatalogRelayList? existingRelayList, - String relayUrl, - TextEditingController controller, - ValueNotifier isOperating, - ) async { - // Validate and normalize URL - final normalizedUrl = validateAndNormalizeRelayUrl(relayUrl); - if (normalizedUrl == null) { - context.showError( - 'Invalid app catalog relay URL', - description: 'Must be a valid WebSocket URL (ws:// or wss://)', - ); - return; - } - - // Check for duplicates (case-insensitive on host) - final existingRelays = existingRelayList?.readRelays ?? {}; - if (isDuplicateRelay(normalizedUrl, existingRelays)) { - context.showError( - 'App catalog relay already exists', - description: 'This relay is already in your list.', - ); - return; - } - - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - context.showError('Sign in required'); - return; - } - - try { - isOperating.value = true; - - // Create partial model with existing relays - final partialRelayList = PartialAppCatalogRelayList(); - for (final relay in existingRelays) { - partialRelayList.addReadRelay(relay); - } - // Add the new relay (normalized) - partialRelayList.addReadRelay(normalizedUrl); - - // Sign the event - final signedRelayList = await partialRelayList.signWith(signer); - - // Save locally and publish to bootstrap relays - await ref.storage.save({signedRelayList}); - await ref.storage.publish({ - signedRelayList, - }, source: const RemoteSource(relays: 'bootstrap')); - - // Increment refresh token to force UI refresh - ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; - - // Invalidate latest releases to force new query with updated relays - // This will make the new relay appear in Debug Info immediately - ref.invalidate(latestReleasesProvider); - ref.read(updatesRefreshProvider.notifier).state++; - ref.read(latestReleasesRefreshProvider.notifier).state++; - - controller.clear(); - - if (context.mounted) { - context.showInfo('App catalog relay added'); - } - } catch (e) { - if (context.mounted) { - context.showError('Failed to add app catalog relay', description: '$e'); - } - } finally { - isOperating.value = false; - } - } - - Future _removeAppCatalogRelay( - BuildContext context, - WidgetRef ref, - AppCatalogRelayList? existingRelayList, - String relayUrl, - ValueNotifier isOperating, - ) async { - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - context.showError('Sign in required'); - return; - } - - // Capture a stable context reference before async operations - // The itemBuilder context may become invalid after widget rebuilds - final navigatorContext = Navigator.of(context).context; - - try { - isOperating.value = true; - - final existingRelays = existingRelayList?.readRelays ?? {}; - - // Create partial model with all relays except the one being removed - final partialRelayList = PartialAppCatalogRelayList(); - for (final relay in existingRelays) { - if (relay != relayUrl) { - partialRelayList.addReadRelay(relay); - } - } - - // Sign the event - final signedRelayList = await partialRelayList.signWith(signer); - - // Save locally and publish to bootstrap relays - await ref.storage.save({signedRelayList}); - await ref.storage.publish({ - signedRelayList, - }, source: const RemoteSource(relays: 'bootstrap')); - - // Show success toast using stable context - if (navigatorContext.mounted) { - navigatorContext.showInfo('App catalog relay removed'); - } - - // Increment refresh token to force UI refresh - ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; - - // Invalidate latest releases to force new query with updated relays - // This will make the removed relay disappear from Debug Info immediately - ref.invalidate(latestReleasesProvider); - ref.read(updatesRefreshProvider.notifier).state++; - ref.read(latestReleasesRefreshProvider.notifier).state++; - - // Cancel subscriptions tied to the removed relay only - await _cancelAppCatalogSubscriptions(ref, {relayUrl}); - } catch (e) { - if (navigatorContext.mounted) { - navigatorContext.showError( - 'Failed to remove app catalog relay', - description: '$e', - ); - } - } finally { - isOperating.value = false; - } - } - - Future _cancelAppCatalogSubscriptions( - WidgetRef ref, - Set relaysToCancel, - ) async { - final poolState = ref.read(poolStateProvider); - final subscriptions = poolState?.subscriptions ?? const {}; - final targets = relaysToCancel.map((r) => r.toLowerCase()).toSet(); - - for (final sub in subscriptions.values) { - final hasTargetRelay = sub.relays.keys.any( - (relay) => targets.contains(relay.toLowerCase()), - ); - if (hasTargetRelay) { - await ref.storage.cancel(sub.request); - } - } - } -} - class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); @@ -1979,8 +1413,8 @@ class _DataManagementSection extends ConsumerWidget { ], ), content: const Text( - 'Clears all cached data (except NWC secret) and restarts the app. ' - 'You will be signed out.', + 'Clears all cached data and restarts the app. ' + 'Your sign-in and wallet connection will be preserved.', ), actions: [ TextButton( diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 7af1ae1..25e5496 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -1,3 +1,4 @@ +import 'package:amber_signer/amber_signer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -34,8 +35,27 @@ class SecureStorageService { final value = await _storage.read(key: _nwcKey); return value?.isNotEmpty == true; } +} - /// Check if the install permission dialog has been shown +/// Persists the AmberSigner pubkey in flutter_secure_storage. +/// This survives app data clears (database deletion) and is encrypted. +class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { + static const _key = 'amber_pubkey'; + + @override + Future persistPubkey(String pubkey) async { + await SecureStorageService._storage.write(key: _key, value: pubkey); + } + + @override + Future loadPubkey() async { + return SecureStorageService._storage.read(key: _key); + } + + @override + Future clearPubkey() async { + await SecureStorageService._storage.delete(key: _key); + } } final secureStorageServiceProvider = Provider( diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 259a48a..1a5942b 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -7,9 +7,6 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/version_utils.dart'; -/// Refresh token for updates subscriptions. -final updatesRefreshProvider = StateProvider((ref) => 0); - /// Categorized apps state class CategorizedApps { const CategorizedApps({ @@ -86,7 +83,6 @@ class CategorizedAppsNotifier extends Notifier { } final platform = ref.read(packageManagerProvider.notifier).platform; - final refreshToken = ref.watch(updatesRefreshProvider); // Query apps with relationships loaded via `and:` final appsState = ref.watch( @@ -108,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates-$refreshToken', + subscriptionPrefix: 'updates', ), ); @@ -117,15 +113,13 @@ class CategorizedAppsNotifier extends Notifier { isLoading: !_hasLoadedOnce, ), StorageError() => CategorizedApps.empty.copyWith(isLoading: false), - StorageData(:final models) => - _categorize(models, installedPackages, refreshToken), + StorageData(:final models) => _categorize(models, installedPackages), }; } CategorizedApps _categorize( List apps, List installedPackages, - int refreshToken, ) { _hasLoadedOnce = true; @@ -183,7 +177,7 @@ class CategorizedAppsNotifier extends Notifier { upToDateApps.sort(byName); // Fetch author profiles in background (fire and forget) - _fetchAuthors(installedApps, refreshToken); + _fetchAuthors(installedApps); return CategorizedApps( automaticUpdates: automaticUpdates, @@ -202,7 +196,7 @@ class CategorizedAppsNotifier extends Notifier { return canUpgrade(installed.version, latest.version); } - void _fetchAuthors(Iterable apps, int refreshToken) { + void _fetchAuthors(Iterable apps) { final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); if (authorPubkeys.isEmpty) return; unawaited( @@ -213,7 +207,7 @@ class CategorizedAppsNotifier extends Notifier { cachedFor: Duration(hours: 2), stream: false, ), - subscriptionPrefix: 'updates-profiles-$refreshToken', + subscriptionPrefix: 'updates-profiles', ), ); } diff --git a/lib/utils/relay_utils.dart b/lib/utils/relay_utils.dart deleted file mode 100644 index 9ddd879..0000000 --- a/lib/utils/relay_utils.dart +++ /dev/null @@ -1,129 +0,0 @@ -/// Utility functions for WebSocket relay URL handling. - -/// Validates and normalizes a relay URL: checks format, removes trailing -/// slashes, lowercases the host. Returns null if invalid. -/// -/// Examples: -/// - ` WSS://Relay.Example.Com/ ` → `wss://relay.example.com` -/// - `wss://relay.example.com/` → `wss://relay.example.com` -/// - `wss://relay.example.com:443/path/` → `wss://relay.example.com:443/path` -/// - `invalid-url` → `null` -String? validateAndNormalizeRelayUrl(String input) { - final trimmed = input.trim(); - if (trimmed.isEmpty) return null; - - final uri = Uri.tryParse(trimmed); - if (uri == null) return null; - - // Must have ws or wss scheme - if (uri.scheme != 'ws' && uri.scheme != 'wss') return null; - - // Must have a host - if (uri.host.isEmpty) return null; - if (!_isValidRelayHost(uri.host)) return null; - - // Rebuild normalized URL: scheme://host[:port][/path] - final buffer = StringBuffer() - ..write(uri.scheme) - ..write('://') - ..write(uri.host.toLowerCase()); - - if (uri.hasPort && uri.port != 0) { - buffer - ..write(':') - ..write(uri.port); - } - - // Add path if present, but remove trailing slashes - var path = uri.path; - while (path.endsWith('/')) { - path = path.substring(0, path.length - 1); - } - if (path.isNotEmpty) { - buffer.write(path); - } - - return buffer.toString(); -} - -bool _isValidRelayHost(String host) { - if (host == 'localhost') return true; - - // IPv4 validation (e.g., 192.168.0.1) - final ipv4Match = RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host); - if (ipv4Match) { - final parts = host.split('.'); - return parts.every((p) { - final value = int.tryParse(p); - return value != null && value >= 0 && value <= 255; - }); - } - - // Domain validation - if (host.length > 253) return false; - final labels = host.split('.'); - if (labels.length < 2) return false; - for (final label in labels) { - if (label.isEmpty || label.length > 63) return false; - if (!RegExp(r'^[a-zA-Z0-9-]+$').hasMatch(label)) return false; - if (label.startsWith('-') || label.endsWith('-')) return false; - } - return true; -} - -/// Normalizes a relay URL for comparison, including default ports. -/// Returns a canonical form: scheme://host:port/path -/// where port is always explicit (defaults: 443 for wss, 80 for ws). -String? _normalizeForComparison(String url) { - final normalized = validateAndNormalizeRelayUrl(url); - if (normalized == null) return null; - - final uri = Uri.parse(normalized); - final defaultPort = uri.scheme == 'wss' ? 443 : 80; - final port = uri.hasPort && uri.port != 0 ? uri.port : defaultPort; - - final buffer = StringBuffer() - ..write(uri.scheme) - ..write('://') - ..write(uri.host.toLowerCase()) - ..write(':') - ..write(port); - - var path = uri.path; - while (path.endsWith('/')) { - path = path.substring(0, path.length - 1); - } - if (path.isNotEmpty) { - buffer.write(path); - } - - return buffer.toString(); -} - -/// Checks if a relay URL already exists in the set. -/// Normalizes both URLs before comparison, considering default ports. -/// Treats wss://relay.com and wss://relay.com:443 as duplicates. -bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { - final newUrlCanonical = _normalizeForComparison(normalizedUrl); - if (newUrlCanonical == null) return false; - - for (final existing in existingRelays) { - final existingCanonical = _normalizeForComparison(existing); - if (existingCanonical == null) continue; - - if (existingCanonical == newUrlCanonical) { - return true; - } - } - return false; -} - -/// Formats a relay URL for display by removing the scheme and trailing slash. -/// -/// Example: `wss://relay.example.com/` → `relay.example.com` -String formatRelayUrlShort(String relayUrl) { - return relayUrl - .replaceAll('wss://', '') - .replaceAll('ws://', '') - .replaceAll(RegExp(r'/$'), ''); -} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 14016cd..d9dd42c 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -7,9 +7,6 @@ import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; -/// Refresh token for latest releases subscriptions. -final latestReleasesRefreshProvider = StateProvider((ref) => 0); - class LatestReleasesContainer extends HookConsumerWidget { const LatestReleasesContainer({ super.key, @@ -303,7 +300,6 @@ class LatestReleasesNotifier extends StateNotifier { void _startQuery() { _sub?.close(); - final refreshToken = ref.read(latestReleasesRefreshProvider); _sub = ref.listen>( query( @@ -325,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest-$refreshToken', + subscriptionPrefix: 'latest', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart new file mode 100644 index 0000000..80efb7d --- /dev/null +++ b/lib/widgets/relay_management_card.dart @@ -0,0 +1,570 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/services/app_restart_service.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Provider for the user's app catalog relay list. +final _appCatalogRelayListProvider = + Provider?>((ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + + if (pubkey == null) { + return null; + } + + return ref.watch( + query( + authors: {pubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'bootstrap', + stream: false, + ), + subscriptionPrefix: 'user-appcatalog-relays', + ), + ); +}); + +/// App Catalog Relay Management Card - manages app catalog relays (kind 10067) +/// These are relays for discovering apps, NOT social relays like Damus/Primal. +/// +/// Changes are accumulated in memory and applied with "Apply Changes" which +/// publishes the relay list and restarts the app with a fresh database. +class RelayManagementCard extends HookConsumerWidget { + const RelayManagementCard({super.key}); + + static const _kDefaultRelay = 'wss://relay.zapstore.dev'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + // Only show when user is signed in + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + final relayUrlController = useTextEditingController(); + final hasText = useState(false); + final isApplying = useState(false); + + // Watch pool state for relay connection status + final poolState = ref.watch(poolStateProvider); + + // Watch remote relay list + final relayListState = ref.watch(_appCatalogRelayListProvider); + final existingRelayList = relayListState?.models.firstOrNull; + final savedRelays = (existingRelayList?.readRelays ?? {}).toList() + ..sort(); + + // If no relays saved, show default relay + final effectiveSavedRelays = + savedRelays.isEmpty ? [_kDefaultRelay] : savedRelays; + + // Local pending state - initialized from effective saved relays + final pendingRelays = useState?>(null); + + // Initialize pending from effective saved when first loaded + useEffect(() { + if (pendingRelays.value == null && relayListState is StorageData) { + pendingRelays.value = effectiveSavedRelays; + } + return null; + }, [relayListState]); + + // Current display relays (pending if modified, else effective saved) + final displayRelays = pendingRelays.value ?? effectiveSavedRelays; + final hasChanges = + pendingRelays.value != null && + !const ListEquality().equals( + pendingRelays.value, + effectiveSavedRelays, + ); + + // Listen to text changes to enable/disable add button + useEffect(() { + void listener() { + hasText.value = relayUrlController.text.trim().isNotEmpty; + } + + relayUrlController.addListener(listener); + return () => relayUrlController.removeListener(listener); + }, [relayUrlController]); + + void addRelay(String relayUrl) { + // Validate and normalize URL + final normalizedUrl = _validateAndNormalizeRelayUrl(relayUrl); + if (normalizedUrl == null) { + context.showError( + 'Invalid relay URL', + description: 'Must be a valid WebSocket URL (ws:// or wss://)', + ); + return; + } + + // Check for duplicates + final currentRelays = pendingRelays.value ?? savedRelays; + if (_isDuplicateRelay(normalizedUrl, currentRelays.toSet())) { + context.showError( + 'Relay already exists', + description: 'This relay is already in your list.', + ); + return; + } + + pendingRelays.value = [...currentRelays, normalizedUrl]..sort(); + relayUrlController.clear(); + } + + void removeRelay(String relayUrl) { + final currentRelays = pendingRelays.value ?? savedRelays; + pendingRelays.value = currentRelays.where((r) => r != relayUrl).toList(); + } + + Future applyChanges() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text('Apply Relay Changes'), + ], + ), + content: const Text( + 'Changing app catalog relays will clear cached app data and restart the app. ' + 'Your sign-in and wallet connection will be preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Apply Changes'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + isApplying.value = true; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError('Sign in required'); + } + return; + } + + // Create and sign the relay list (use displayRelays which reflects user's intent) + final partialRelayList = PartialAppCatalogRelayList(); + for (final relay in displayRelays) { + partialRelayList.addReadRelay(relay); + } + final signedRelayList = await partialRelayList.signWith(signer); + + // Publish to bootstrap relays + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + + // Show loading dialog + if (context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: AppColors.darkSkeletonHighlight, + backgroundColor: AppColors.darkSkeletonBase, + ), + SizedBox(height: 16), + Text('Restarting...'), + ], + ), + ), + ); + } + + // Restart app with database clear + await restartApp(); + } catch (e) { + isApplying.value = false; + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + context.showError( + 'Failed to apply relay changes', + description: '$e', + actions: [('Retry', () => applyChanges())], + ); + } + } + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'App Catalog Relays', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Info text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'These relays are used to discover apps, not for social content.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Current relays list + if (displayRelays.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Using default catalog relays. Add a relay to override.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.separated( + shrinkWrap: true, + physics: displayRelays.length > 4 + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: displayRelays.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final relayUrl = displayRelays[index]; + + // Get relay connection status from pool state + final statusColor = + _getRelayStatusColor(poolState, relayUrl); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Status dot + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + boxShadow: statusColor != Colors.grey + ? [ + BoxShadow( + color: + statusColor.withValues(alpha: 0.4), + blurRadius: 4, + spreadRadius: 1, + ), + ] + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + onPressed: isApplying.value + ? null + : () => removeRelay(relayUrl), + tooltip: 'Remove relay', + ), + ), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 8), + + // Add relay input + Row( + children: [ + Expanded( + child: TextField( + controller: relayUrlController, + enabled: !isApplying.value, + decoration: InputDecoration( + hintText: 'wss://relay.example.com', + hintStyle: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + ), + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + onSubmitted: isApplying.value + ? null + : (value) { + if (value.trim().isNotEmpty) { + addRelay(value.trim()); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: isApplying.value || !hasText.value + ? null + : () { + final url = relayUrlController.text.trim(); + if (url.isNotEmpty) { + addRelay(url); + } + }, + icon: const Icon(Icons.add), + tooltip: 'Add relay', + style: IconButton.styleFrom( + backgroundColor: isApplying.value || !hasText.value + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.primaryContainer, + foregroundColor: isApplying.value || !hasText.value + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4) + : Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + + // Apply Changes button (only shown when there are changes) + if (hasChanges) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isApplying.value ? null : applyChanges, + icon: isApplying.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh), + label: Text( + isApplying.value ? 'Applying...' : 'Apply Changes', + ), + ), + ), + ], + ], + ), + ), + ); + } + + /// Validates and normalizes a relay URL. + /// Uses wss:// unless ws:// is explicitly specified. + /// If no protocol is provided, wss:// is assumed. + static String? _validateAndNormalizeRelayUrl(String input) { + var url = input.trim(); + + // Add wss:// if no protocol specified + if (!url.startsWith('ws://') && !url.startsWith('wss://')) { + url = 'wss://$url'; + } + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) return null; + + // Use wss:// unless ws:// is explicitly specified + final scheme = uri.scheme == 'ws' ? 'ws' : 'wss'; + final path = uri.path.endsWith('/') + ? uri.path.substring(0, uri.path.length - 1) + : uri.path; + + return Uri( + scheme: scheme, + host: uri.host.toLowerCase(), + port: uri.hasPort ? uri.port : null, + path: path.isEmpty ? null : path, + ).toString(); + } + + /// Checks if a relay URL already exists in the set (case-insensitive). + static bool _isDuplicateRelay( + String normalizedUrl, + Set existingRelays, + ) { + final newLower = normalizedUrl.toLowerCase(); + for (final existing in existingRelays) { + if (existing.toLowerCase() == newLower) { + return true; + } + } + return false; + } + + /// Gets the best connection status color for a relay from pool state. + /// Returns green for streaming/loading, yellow for connecting/waiting, + /// red for failed, grey for disconnected/not found. + static Color _getRelayStatusColor(PoolState? poolState, String relayUrl) { + if (poolState == null) return Colors.grey; + + final subscriptions = poolState.subscriptions; + final relayLower = relayUrl.toLowerCase(); + + RelaySubPhase? bestPhase; + + for (final sub in subscriptions.values) { + for (final entry in sub.relays.entries) { + if (entry.key.toLowerCase() == relayLower) { + final phase = entry.value.phase; + // Prioritize: streaming > loading > connecting > waiting > others + if (bestPhase == null || + _phasePriority(phase) > _phasePriority(bestPhase)) { + bestPhase = phase; + } + } + } + } + + if (bestPhase == null) return Colors.grey; + + return switch (bestPhase) { + RelaySubPhase.streaming => Colors.green, + RelaySubPhase.loading => Colors.green, + RelaySubPhase.connecting => Colors.amber, + RelaySubPhase.waiting => Colors.amber, + RelaySubPhase.failed => Colors.red, + RelaySubPhase.disconnected => Colors.grey, + RelaySubPhase.closed => Colors.grey, + }; + } + + static int _phasePriority(RelaySubPhase phase) { + return switch (phase) { + RelaySubPhase.streaming => 6, + RelaySubPhase.loading => 5, + RelaySubPhase.connecting => 4, + RelaySubPhase.waiting => 3, + RelaySubPhase.failed => 2, + RelaySubPhase.disconnected => 1, + RelaySubPhase.closed => 0, + }; + } +} diff --git a/pubspec.lock b/pubspec.lock index 96efb8a..41adf14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,8 +5,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4d065ba" - resolved-ref: "4d065badcef18ea397dff40c46fcce114ecca12a" + ref: "6cbedbe" + resolved-ref: "6cbedbe6ae58a952c564979002258840921b041b" url: "https://github.com/purplebase/amber_signer" source: git version: "0.2.0" diff --git a/pubspec.yaml b/pubspec.yaml index 453f415..cda80bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,7 +71,7 @@ dependency_overrides: # path: ../amber_signer git: url: https://github.com/purplebase/amber_signer - ref: 4d065ba + ref: 6cbedbe dev_dependencies: flutter_test: