mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Restart app to apply relay changes
This commit is contained in:
+5
-1
@@ -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<void>((ref) async {
|
||||
});
|
||||
|
||||
// AmberSigner provider for Nostr authentication
|
||||
final amberSignerProvider = Provider<AmberSigner>(AmberSigner.new);
|
||||
// Uses SecureStoragePubkeyPersistence to survive database clears
|
||||
final amberSignerProvider = Provider<AmberSigner>(
|
||||
(ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()),
|
||||
);
|
||||
|
||||
Future<void> _attemptAutoSignIn(Ref ref) async {
|
||||
try {
|
||||
|
||||
@@ -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<int>((ref) => 0);
|
||||
|
||||
/// Provider for the user's app catalog relay list.
|
||||
/// Watches the active pubkey and refresh token to enable invalidation.
|
||||
final _appCatalogRelayListProvider =
|
||||
Provider<StorageState<AppCatalogRelayList>?>((ref) {
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final refreshToken = ref.watch(_appCatalogRelaysRefreshProvider);
|
||||
|
||||
if (pubkey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ref.watch(
|
||||
query<AppCatalogRelayList>(
|
||||
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<String>('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 ?? <String>{}).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<String>(
|
||||
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<void> _addAppCatalogRelay(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppCatalogRelayList? existingRelayList,
|
||||
String relayUrl,
|
||||
TextEditingController controller,
|
||||
ValueNotifier<bool> 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 ?? <String>{};
|
||||
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<void> _removeAppCatalogRelay(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppCatalogRelayList? existingRelayList,
|
||||
String relayUrl,
|
||||
ValueNotifier<bool> 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 ?? <String>{};
|
||||
|
||||
// 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<void> _cancelAppCatalogSubscriptions(
|
||||
WidgetRef ref,
|
||||
Set<String> 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(
|
||||
|
||||
@@ -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<void> persistPubkey(String pubkey) async {
|
||||
await SecureStorageService._storage.write(key: _key, value: pubkey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> loadPubkey() async {
|
||||
return SecureStorageService._storage.read(key: _key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPubkey() async {
|
||||
await SecureStorageService._storage.delete(key: _key);
|
||||
}
|
||||
}
|
||||
|
||||
final secureStorageServiceProvider = Provider<SecureStorageService>(
|
||||
|
||||
@@ -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<int>((ref) => 0);
|
||||
|
||||
/// Categorized apps state
|
||||
class CategorizedApps {
|
||||
const CategorizedApps({
|
||||
@@ -86,7 +83,6 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
}
|
||||
|
||||
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<CategorizedApps> {
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'updates-$refreshToken',
|
||||
subscriptionPrefix: 'updates',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -117,15 +113,13 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
isLoading: !_hasLoadedOnce,
|
||||
),
|
||||
StorageError() => CategorizedApps.empty.copyWith(isLoading: false),
|
||||
StorageData(:final models) =>
|
||||
_categorize(models, installedPackages, refreshToken),
|
||||
StorageData(:final models) => _categorize(models, installedPackages),
|
||||
};
|
||||
}
|
||||
|
||||
CategorizedApps _categorize(
|
||||
List<App> apps,
|
||||
List<PackageInfo> installedPackages,
|
||||
int refreshToken,
|
||||
) {
|
||||
_hasLoadedOnce = true;
|
||||
|
||||
@@ -183,7 +177,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
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<CategorizedApps> {
|
||||
return canUpgrade(installed.version, latest.version);
|
||||
}
|
||||
|
||||
void _fetchAuthors(Iterable<App> apps, int refreshToken) {
|
||||
void _fetchAuthors(Iterable<App> apps) {
|
||||
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
|
||||
if (authorPubkeys.isEmpty) return;
|
||||
unawaited(
|
||||
@@ -213,7 +207,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
cachedFor: Duration(hours: 2),
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'updates-profiles-$refreshToken',
|
||||
subscriptionPrefix: 'updates-profiles',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String> 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'/$'), '');
|
||||
}
|
||||
@@ -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<int>((ref) => 0);
|
||||
|
||||
class LatestReleasesContainer extends HookConsumerWidget {
|
||||
const LatestReleasesContainer({
|
||||
super.key,
|
||||
@@ -303,7 +300,6 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
|
||||
void _startQuery() {
|
||||
_sub?.close();
|
||||
final refreshToken = ref.read(latestReleasesRefreshProvider);
|
||||
|
||||
_sub = ref.listen<StorageState<App>>(
|
||||
query<App>(
|
||||
@@ -325,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
},
|
||||
// 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
|
||||
|
||||
@@ -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<StorageState<AppCatalogRelayList>?>((ref) {
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
|
||||
if (pubkey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ref.watch(
|
||||
query<AppCatalogRelayList>(
|
||||
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 ?? <String>{}).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<List<String>?>(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<String>().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<void> applyChanges() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<String> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user