From a953ebbcf49b8ad5296007a9eb69c59e4b74cbfb Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 20 Jan 2026 22:24:04 -0300 Subject: [PATCH] - Add app catalog relay management to profile screen. - Introduce refresh tokens for updates and latest releases subscriptions to ensure UI updates. - Implement utility functions for relay URL handling. --- lib/screens/profile_screen.dart | 484 +++++++++++++++++++++ lib/services/updates_service.dart | 16 +- lib/utils/relay_utils.dart | 96 ++++ lib/widgets/latest_releases_container.dart | 6 +- 4 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 lib/utils/relay_utils.dart diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5fcdef5..dc000b3 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,16 +17,48 @@ 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'; // 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', + ), + ); + }); + /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @@ -64,6 +96,11 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), + // App Catalog Relay Management Section + const _AppCatalogRelayManagementSection(), + + const SizedBox(height: 16), + // Data Management Section const _DataManagementSection(), @@ -1353,6 +1390,453 @@ 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://'); + + // 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.onSurface.withValues(alpha: 0.6), + ), + ), + 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 + ...relays.map((relayUrl) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + 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( + children: [ + Icon( + Icons.cloud, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: isOperating.value + ? null + : () => _removeAppCatalogRelay( + context, + ref, + existingRelayList, + relayUrl, + isOperating, + ), + tooltip: 'Remove app catalog relay', + ), + ], + ), + ); + }), + + const SizedBox(height: 8), + + // Add relay input + Row( + children: [ + 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: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + underline: const SizedBox.shrink(), + isDense: true, + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: relayUrlController, + decoration: InputDecoration( + hintText: 'relay.example.com', + hintStyle: TextStyle( + 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: 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, + ); + return IconButton( + onPressed: isLoading || isOperating.value ? 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: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: 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; + } + + 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')); + + // 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}); + + if (context.mounted) { + context.showInfo('App catalog relay removed'); + } + } catch (e) { + if (context.mounted) { + context.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(); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1a5942b..259a48a 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -7,6 +7,9 @@ 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({ @@ -83,6 +86,7 @@ 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( @@ -104,7 +108,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates', + subscriptionPrefix: 'updates-$refreshToken', ), ); @@ -113,13 +117,15 @@ class CategorizedAppsNotifier extends Notifier { isLoading: !_hasLoadedOnce, ), StorageError() => CategorizedApps.empty.copyWith(isLoading: false), - StorageData(:final models) => _categorize(models, installedPackages), + StorageData(:final models) => + _categorize(models, installedPackages, refreshToken), }; } CategorizedApps _categorize( List apps, List installedPackages, + int refreshToken, ) { _hasLoadedOnce = true; @@ -177,7 +183,7 @@ class CategorizedAppsNotifier extends Notifier { upToDateApps.sort(byName); // Fetch author profiles in background (fire and forget) - _fetchAuthors(installedApps); + _fetchAuthors(installedApps, refreshToken); return CategorizedApps( automaticUpdates: automaticUpdates, @@ -196,7 +202,7 @@ class CategorizedAppsNotifier extends Notifier { return canUpgrade(installed.version, latest.version); } - void _fetchAuthors(Iterable apps) { + void _fetchAuthors(Iterable apps, int refreshToken) { final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); if (authorPubkeys.isEmpty) return; unawaited( @@ -207,7 +213,7 @@ class CategorizedAppsNotifier extends Notifier { cachedFor: Duration(hours: 2), stream: false, ), - subscriptionPrefix: 'updates-profiles', + subscriptionPrefix: 'updates-profiles-$refreshToken', ), ); } diff --git a/lib/utils/relay_utils.dart b/lib/utils/relay_utils.dart new file mode 100644 index 0000000..4ed93bd --- /dev/null +++ b/lib/utils/relay_utils.dart @@ -0,0 +1,96 @@ +/// 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; +} + +/// Checks if a relay URL already exists in the set. +/// Normalizes both URLs before comparison for robustness. +bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { + for (final existing in existingRelays) { + final existingNormalized = validateAndNormalizeRelayUrl(existing); + if (existingNormalized == null) continue; + + if (existingNormalized == normalizedUrl) { + 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 d9dd42c..14016cd 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -7,6 +7,9 @@ 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, @@ -300,6 +303,7 @@ class LatestReleasesNotifier extends StateNotifier { void _startQuery() { _sub?.close(); + final refreshToken = ref.read(latestReleasesRefreshProvider); _sub = ref.listen>( query( @@ -321,7 +325,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest', + subscriptionPrefix: 'latest-$refreshToken', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head