mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Data loading improvements, remove unused code
This commit is contained in:
+13
-11
@@ -58,11 +58,12 @@ void main() {
|
||||
/// Global error handler that reports errors via NIP-44 encrypted DMs
|
||||
void _errorHandler(Object exception, StackTrace? stack) {
|
||||
// Report error asynchronously (fire and forget)
|
||||
unawaited(
|
||||
_providerContainer
|
||||
.read(errorReportingServiceProvider)
|
||||
.reportError(exception, stack),
|
||||
);
|
||||
// TODO: Disabled until careful review
|
||||
// unawaited(
|
||||
// _providerContainer
|
||||
// .read(errorReportingServiceProvider)
|
||||
// .reportError(exception, stack),
|
||||
// );
|
||||
}
|
||||
|
||||
class ZapstoreApp extends HookConsumerWidget {
|
||||
@@ -218,6 +219,7 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
initializationProvider(
|
||||
StorageConfiguration(
|
||||
databasePath: dbPath,
|
||||
defaultQuerySource: LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||
defaultRelays: {
|
||||
'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'},
|
||||
'AppCatalog': {'wss://relay.zapstore.dev'},
|
||||
@@ -282,12 +284,12 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final downloadService = _ref.read(downloadServiceProvider.notifier);
|
||||
|
||||
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Notify download service that app is in foreground
|
||||
// This will process any pending installations
|
||||
unawaited(downloadService.setAppForeground(true));
|
||||
|
||||
|
||||
// App regained focus - re-sync installed packages from Android system
|
||||
// This will detect any apps installed/uninstalled outside of Zapstore
|
||||
unawaited(
|
||||
@@ -302,10 +304,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
_ref.read(storageNotifierProvider.notifier)
|
||||
as PurplebaseStorageNotifier;
|
||||
notifier.ensureConnected();
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
// Notify download service that app is in background
|
||||
unawaited(downloadService.setAppForeground(false));
|
||||
}
|
||||
|
||||
@@ -29,6 +29,31 @@ class DeveloperScreen extends HookConsumerWidget {
|
||||
final profileAsync = ref.watch(profileProvider(pubkey));
|
||||
final profile = profileAsync.value;
|
||||
|
||||
// Shared developer apps query (AppCatalog)
|
||||
final developerAppsState = ref.watch(
|
||||
query<App>(
|
||||
authors: {pubkey},
|
||||
tags: {
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
limit: 20,
|
||||
and: (app) => {
|
||||
app.latestRelease,
|
||||
app.latestRelease.value?.latestMetadata,
|
||||
},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: true,
|
||||
background: true,
|
||||
),
|
||||
andSource: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'developer-apps',
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
@@ -41,7 +66,7 @@ class DeveloperScreen extends HookConsumerWidget {
|
||||
),
|
||||
|
||||
// Zaps list for all developer's apps
|
||||
_DeveloperZapsList(pubkey: pubkey),
|
||||
_DeveloperZapsList(pubkey: pubkey, appsState: developerAppsState),
|
||||
|
||||
// Tabs for apps and info
|
||||
Expanded(
|
||||
@@ -61,6 +86,7 @@ class DeveloperScreen extends HookConsumerWidget {
|
||||
_DeveloperAppsTab(
|
||||
pubkey: pubkey,
|
||||
scrollController: scrollController,
|
||||
appsState: developerAppsState,
|
||||
),
|
||||
_DeveloperAboutTab(
|
||||
profile: profile,
|
||||
@@ -107,26 +133,15 @@ class _DeveloperAppsTab extends HookConsumerWidget {
|
||||
const _DeveloperAppsTab({
|
||||
required this.pubkey,
|
||||
required this.scrollController,
|
||||
required this.appsState,
|
||||
});
|
||||
|
||||
final String pubkey;
|
||||
final ScrollController scrollController;
|
||||
final StorageState<App> appsState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Query apps by this specific developer
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: {pubkey}, // Filter by developer pubkey
|
||||
limit: 100,
|
||||
tags: {
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
and: (app) => {app.latestRelease},
|
||||
subscriptionPrefix: 'developer-apps',
|
||||
),
|
||||
);
|
||||
|
||||
// Query author profile from 'social' relay group
|
||||
final authorAsync = ref.watch(profileProvider(pubkey));
|
||||
final author = authorAsync.value;
|
||||
@@ -142,11 +157,10 @@ class _DeveloperAppsTab extends HookConsumerWidget {
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) => AppCard(isLoading: true),
|
||||
),
|
||||
StorageError() => _buildErrorState(
|
||||
StorageError() => _buildErrorState(context, appsState.toString()),
|
||||
StorageData(:final models) when models.isEmpty => _buildEmptyState(
|
||||
context,
|
||||
appsState.toString(),
|
||||
),
|
||||
StorageData(:final models) when models.isEmpty => _buildEmptyState(context),
|
||||
StorageData(:final models) => _buildAppsList(
|
||||
context,
|
||||
models,
|
||||
@@ -202,11 +216,7 @@ class _DeveloperAppsTab extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppsList(
|
||||
BuildContext context,
|
||||
List<App> apps,
|
||||
Profile? author,
|
||||
) {
|
||||
Widget _buildAppsList(BuildContext context, List<App> apps, Profile? author) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: apps.length,
|
||||
@@ -222,26 +232,13 @@ class _DeveloperAppsTab extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
class _DeveloperZapsList extends HookConsumerWidget {
|
||||
const _DeveloperZapsList({required this.pubkey});
|
||||
const _DeveloperZapsList({required this.pubkey, required this.appsState});
|
||||
|
||||
final String pubkey;
|
||||
final StorageState<App> appsState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Query all apps by this developer with their latest metadata
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: {pubkey},
|
||||
tags: {
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
limit: 100,
|
||||
and: (app) => {app.latestRelease},
|
||||
source: LocalAndRemoteSource(stream: false, background: true),
|
||||
subscriptionPrefix: 'developer-all-apps',
|
||||
),
|
||||
);
|
||||
|
||||
// Get all apps
|
||||
final apps = appsState is StorageData ? appsState.models : <App>[];
|
||||
|
||||
@@ -269,36 +266,27 @@ class _DeveloperZapsList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Query zaps on app addressable IDs
|
||||
final zapsOnAppsState = ref.watch(
|
||||
final zapTags = <String, Set<String>>{
|
||||
...allAppTags,
|
||||
if (metadataIds.isNotEmpty) '#e': metadataIds,
|
||||
};
|
||||
|
||||
if (zapTags.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Single zap query across app addressable IDs and metadata
|
||||
final zapsState = ref.watch(
|
||||
query<Zap>(
|
||||
tags: allAppTags,
|
||||
source: LocalAndRemoteSource(relays: 'social'),
|
||||
tags: zapTags,
|
||||
source: const LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.author, zap.zapRequest},
|
||||
andSource: LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'developer-zaps-apps',
|
||||
andSource: const LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'developer-zaps',
|
||||
),
|
||||
);
|
||||
|
||||
// Query zaps on file metadata
|
||||
final zapsOnMetadataState = metadataIds.isNotEmpty
|
||||
? ref.watch(
|
||||
query<Zap>(
|
||||
tags: {'#e': metadataIds},
|
||||
source: LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.author, zap.zapRequest},
|
||||
andSource: LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'developer-zaps-metadata',
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Combine all zaps
|
||||
final allZaps = <Zap>{
|
||||
if (zapsOnAppsState is StorageData) ...zapsOnAppsState.models,
|
||||
if (zapsOnMetadataState != null && zapsOnMetadataState is StorageData)
|
||||
...zapsOnMetadataState.models,
|
||||
};
|
||||
final allZaps = zapsState is StorageData ? zapsState.models : const <Zap>[];
|
||||
|
||||
if (allZaps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
@@ -39,9 +39,7 @@ class SearchScreen extends HookConsumerWidget {
|
||||
},
|
||||
and: (app) => {
|
||||
app.latestRelease,
|
||||
// Load nested FileMetadata from latestRelease (same relay group)
|
||||
if (app.latestRelease.value != null)
|
||||
app.latestRelease.value!.latestMetadata,
|
||||
app.latestRelease.value?.latestMetadata,
|
||||
},
|
||||
// Force the search to hit the default relay group (relay.zapstore.dev)
|
||||
// so a connection appears in Debug Info when searching.
|
||||
|
||||
@@ -24,7 +24,7 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
background: true, // Don't block on relay response
|
||||
background: false, // Single fetch; no live streaming
|
||||
),
|
||||
subscriptionPrefix: 'user-bookmarks',
|
||||
),
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
|
||||
/// Cache duration before fetching from remote relays
|
||||
const kProfileCacheDuration = Duration(hours: 2);
|
||||
|
||||
/// Tracks last fetch times for profiles by pubkey
|
||||
final _profileFetchTimesProvider = StateProvider<Map<String, DateTime>>(
|
||||
(ref) => {},
|
||||
);
|
||||
|
||||
/// Tracks profile update timestamps to trigger reactivity
|
||||
final _profileUpdateTriggerProvider = StateProvider<Map<String, DateTime>>(
|
||||
(ref) => {},
|
||||
);
|
||||
|
||||
final _profileRemoteFetchProvider = FutureProvider.autoDispose
|
||||
.family<void, String>((ref, pubkey) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final request = RequestFilter<Profile>(authors: {pubkey}).toRequest();
|
||||
|
||||
try {
|
||||
final profiles = await storage.query(
|
||||
request,
|
||||
source: const RemoteSource(
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
background: false,
|
||||
),
|
||||
);
|
||||
|
||||
// Save to local storage
|
||||
if (profiles.isNotEmpty) {
|
||||
await storage.save(profiles.toSet());
|
||||
}
|
||||
|
||||
ref.read(_profileFetchTimesProvider.notifier).update((state) {
|
||||
return {...state, pubkey: DateTime.now()};
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace');
|
||||
}
|
||||
});
|
||||
|
||||
/// Service for managing profile fetches with cache awareness
|
||||
class ProfileService {
|
||||
ProfileService(this.ref);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
/// Fetches profiles from remote, respecting cache duration.
|
||||
/// Pass a Set of pubkeys - use Set of 1 for single profile.
|
||||
Future<void> fetchProfiles(Set<String> pubkeys) async {
|
||||
if (pubkeys.isEmpty) return;
|
||||
|
||||
final fetchTimes = ref.read(_profileFetchTimesProvider);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Filter out recently fetched profiles
|
||||
final staleProfiles = pubkeys.where((pubkey) {
|
||||
final lastFetch = fetchTimes[pubkey];
|
||||
return lastFetch == null ||
|
||||
now.difference(lastFetch) > kProfileCacheDuration;
|
||||
}).toSet();
|
||||
|
||||
if (staleProfiles.isEmpty) return;
|
||||
|
||||
try {
|
||||
final profiles = await ref.read(storageNotifierProvider.notifier).query(
|
||||
RequestFilter<Profile>(authors: staleProfiles).toRequest(),
|
||||
source: const RemoteSource(
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
background: false,
|
||||
),
|
||||
);
|
||||
|
||||
// Save to local storage so they're available for LocalSource queries
|
||||
if (profiles.isNotEmpty) {
|
||||
await ref.read(storageNotifierProvider.notifier).save(profiles.toSet());
|
||||
|
||||
// Trigger reactivity by updating timestamp for each profile
|
||||
ref.read(_profileUpdateTriggerProvider.notifier).update((state) {
|
||||
final updated = {...state};
|
||||
for (final profile in profiles) {
|
||||
updated[profile.event.pubkey] = DateTime.now();
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Update fetch times for all profiles
|
||||
ref.read(_profileFetchTimesProvider.notifier).update((state) {
|
||||
final updated = {...state};
|
||||
for (final pubkey in staleProfiles) {
|
||||
updated[pubkey] = now;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to fetch profiles: $error\n$stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate cache for a specific profile, forcing a remote fetch on next access.
|
||||
void invalidateProfile(String pubkey) {
|
||||
ref.read(_profileFetchTimesProvider.notifier).update((state) {
|
||||
final updated = {...state};
|
||||
updated.remove(pubkey);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/// Invalidate cache for all profiles, forcing remote fetches on next access.
|
||||
/// Useful for pull-to-refresh scenarios.
|
||||
void invalidateAllProfiles() {
|
||||
ref.read(_profileFetchTimesProvider.notifier).state = {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the profile service
|
||||
final profileServiceProvider = Provider((ref) => ProfileService(ref));
|
||||
|
||||
/// Provider family for accessing profiles with smart caching
|
||||
///
|
||||
/// Automatically determines whether to trigger a remote refresh:
|
||||
/// - If profile was fetched from remote < 2 hours ago: keep cached data
|
||||
/// - If profile was never fetched or > 2 hours ago: run a background remote fetch
|
||||
/// - Reacts to profile updates via _profileUpdateTriggerProvider
|
||||
final profileProvider =
|
||||
AutoDisposeProvider.family<AsyncValue<Profile?>, String>((ref, pubkey) {
|
||||
final lastFetchTime = ref.watch(
|
||||
_profileFetchTimesProvider.select((state) => state[pubkey]),
|
||||
);
|
||||
|
||||
// Watch update trigger to react when profiles are saved
|
||||
ref.watch(
|
||||
_profileUpdateTriggerProvider.select((state) => state[pubkey]),
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
final shouldFetchRemote =
|
||||
lastFetchTime == null ||
|
||||
now.difference(lastFetchTime) > kProfileCacheDuration;
|
||||
|
||||
// Trigger background fetch if cache is stale
|
||||
if (shouldFetchRemote) {
|
||||
ref.watch(_profileRemoteFetchProvider(pubkey));
|
||||
}
|
||||
|
||||
// Read from local storage (will re-run when update trigger changes)
|
||||
final profileState = ref.watch(
|
||||
query<Profile>(authors: {pubkey}, source: const LocalSource()),
|
||||
);
|
||||
|
||||
return switch (profileState) {
|
||||
StorageLoading() => const AsyncValue.loading(),
|
||||
StorageError(:final exception) => AsyncValue.error(
|
||||
exception,
|
||||
StackTrace.current,
|
||||
),
|
||||
StorageData(:final models) => AsyncValue.data(
|
||||
models.isNotEmpty ? models.first : null,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
@@ -5,6 +7,9 @@ import 'package:models/models.dart';
|
||||
/// Cache duration before fetching from remote relays
|
||||
const kProfileCacheDuration = Duration(hours: 2);
|
||||
|
||||
/// Debounce duration for batching profile requests
|
||||
const kProfileFetchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Tracks last fetch times for profiles by pubkey
|
||||
final _profileFetchTimesProvider = StateProvider<Map<String, DateTime>>(
|
||||
(ref) => {},
|
||||
@@ -15,71 +20,80 @@ final _profileUpdateTriggerProvider = StateProvider<Map<String, DateTime>>(
|
||||
(ref) => {},
|
||||
);
|
||||
|
||||
final _profileRemoteFetchProvider = FutureProvider.autoDispose
|
||||
.family<void, String>((ref, pubkey) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final request = RequestFilter<Profile>(authors: {pubkey}).toRequest();
|
||||
|
||||
try {
|
||||
final profiles = await storage.query(
|
||||
request,
|
||||
source: const RemoteSource(
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
background: false,
|
||||
),
|
||||
);
|
||||
|
||||
// Save to local storage
|
||||
if (profiles.isNotEmpty) {
|
||||
await storage.save(profiles.toSet());
|
||||
}
|
||||
|
||||
ref.read(_profileFetchTimesProvider.notifier).update((state) {
|
||||
return {...state, pubkey: DateTime.now()};
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to fetch profile $pubkey: $error\n$stackTrace');
|
||||
}
|
||||
});
|
||||
|
||||
/// Service for managing profile fetches with cache awareness
|
||||
/// Service for managing profile fetches with cache awareness and debounced batching
|
||||
class ProfileService {
|
||||
ProfileService(this.ref);
|
||||
|
||||
|
||||
final Ref ref;
|
||||
|
||||
/// Fetches profiles from remote, respecting cache duration.
|
||||
/// Pass a Set of pubkeys - use Set of 1 for single profile.
|
||||
Future<void> fetchProfiles(Set<String> pubkeys) async {
|
||||
if (pubkeys.isEmpty) return;
|
||||
|
||||
final fetchTimes = ref.read(_profileFetchTimesProvider);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Filter out recently fetched profiles
|
||||
final staleProfiles = pubkeys.where((pubkey) {
|
||||
final lastFetch = fetchTimes[pubkey];
|
||||
return lastFetch == null ||
|
||||
now.difference(lastFetch) > kProfileCacheDuration;
|
||||
}).toSet();
|
||||
|
||||
if (staleProfiles.isEmpty) return;
|
||||
|
||||
|
||||
/// Pending pubkeys waiting to be fetched (collected during debounce window)
|
||||
final Set<String> _pendingPubkeys = {};
|
||||
|
||||
/// Pubkeys currently being fetched (prevents duplicate in-flight requests)
|
||||
final Set<String> _inFlightPubkeys = {};
|
||||
|
||||
/// Debounce timer for batching profile fetches
|
||||
Timer? _debounceTimer;
|
||||
|
||||
/// Request a profile fetch with debouncing.
|
||||
/// Requests are collected and batched after the debounce window.
|
||||
void requestProfile(String pubkey) {
|
||||
// Skip if already pending or in-flight
|
||||
if (_pendingPubkeys.contains(pubkey) || _inFlightPubkeys.contains(pubkey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingPubkeys.add(pubkey);
|
||||
|
||||
// Reset/start debounce timer
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(kProfileFetchDebounce, _executeBatchFetch);
|
||||
}
|
||||
|
||||
/// Executes batch fetch for all pending profiles
|
||||
Future<void> _executeBatchFetch() async {
|
||||
if (_pendingPubkeys.isEmpty) return;
|
||||
|
||||
// Move pending to in-flight
|
||||
final pubkeysToFetch = Set<String>.from(_pendingPubkeys);
|
||||
_pendingPubkeys.clear();
|
||||
_inFlightPubkeys.addAll(pubkeysToFetch);
|
||||
|
||||
try {
|
||||
final profiles = await ref.read(storageNotifierProvider.notifier).query(
|
||||
RequestFilter<Profile>(authors: staleProfiles).toRequest(),
|
||||
await _fetchProfilesBatch(pubkeysToFetch);
|
||||
} finally {
|
||||
// Remove from in-flight when done
|
||||
_inFlightPubkeys.removeAll(pubkeysToFetch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal batch fetch implementation
|
||||
Future<void> _fetchProfilesBatch(Set<String> pubkeys) async {
|
||||
if (pubkeys.isEmpty) return;
|
||||
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final now = DateTime.now();
|
||||
|
||||
debugPrint('ProfileService: Batch fetching ${pubkeys.length} profiles');
|
||||
|
||||
try {
|
||||
// Use RemoteSource with background: false to ensure we wait for relay data.
|
||||
// This is fast because we're batching - one request for all profiles.
|
||||
final profiles = await storage.query(
|
||||
RequestFilter<Profile>(authors: pubkeys).toRequest(),
|
||||
source: const RemoteSource(
|
||||
relays: 'social',
|
||||
relays: {'social', 'vertex'},
|
||||
stream: false,
|
||||
background: false,
|
||||
background: false, // Wait for EOSE - fast since batched
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
debugPrint('ProfileService: Fetched ${profiles.length} profiles');
|
||||
|
||||
// Save to local storage so they're available for LocalSource queries
|
||||
if (profiles.isNotEmpty) {
|
||||
await ref.read(storageNotifierProvider.notifier).save(profiles.toSet());
|
||||
|
||||
await storage.save(profiles.toSet());
|
||||
|
||||
// Trigger reactivity by updating timestamp for each profile
|
||||
ref.read(_profileUpdateTriggerProvider.notifier).update((state) {
|
||||
final updated = {...state};
|
||||
@@ -89,17 +103,48 @@ class ProfileService {
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Update fetch times for all profiles
|
||||
|
||||
// Update fetch times for all requested pubkeys (even if not found)
|
||||
ref.read(_profileFetchTimesProvider.notifier).update((state) {
|
||||
final updated = {...state};
|
||||
for (final pubkey in staleProfiles) {
|
||||
for (final pubkey in pubkeys) {
|
||||
updated[pubkey] = now;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('Failed to fetch profiles: $error\n$stackTrace');
|
||||
debugPrint(
|
||||
'ProfileService: Failed to batch fetch profiles: $error\n$stackTrace',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches profiles from remote, respecting cache duration.
|
||||
/// Pass a Set of pubkeys - use Set of 1 for single profile.
|
||||
/// This method bypasses debouncing for immediate batch fetches.
|
||||
Future<void> fetchProfiles(Set<String> pubkeys) async {
|
||||
if (pubkeys.isEmpty) return;
|
||||
|
||||
final fetchTimes = ref.read(_profileFetchTimesProvider);
|
||||
final now = DateTime.now();
|
||||
|
||||
// Filter out recently fetched profiles and in-flight requests
|
||||
final staleProfiles = pubkeys.where((pubkey) {
|
||||
if (_inFlightPubkeys.contains(pubkey)) return false;
|
||||
final lastFetch = fetchTimes[pubkey];
|
||||
return lastFetch == null ||
|
||||
now.difference(lastFetch) > kProfileCacheDuration;
|
||||
}).toSet();
|
||||
|
||||
if (staleProfiles.isEmpty) return;
|
||||
|
||||
// Mark as in-flight
|
||||
_inFlightPubkeys.addAll(staleProfiles);
|
||||
|
||||
try {
|
||||
await _fetchProfilesBatch(staleProfiles);
|
||||
} finally {
|
||||
_inFlightPubkeys.removeAll(staleProfiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,36 +167,26 @@ class ProfileService {
|
||||
/// Provider for the profile service
|
||||
final profileServiceProvider = Provider((ref) => ProfileService(ref));
|
||||
|
||||
/// Provider family for accessing profiles with smart caching
|
||||
/// Provider family for accessing profiles with reactive updates
|
||||
///
|
||||
/// Automatically determines whether to trigger a remote refresh:
|
||||
/// - If profile was fetched from remote < 2 hours ago: keep cached data
|
||||
/// - If profile was never fetched or > 2 hours ago: run a background remote fetch
|
||||
/// - Reacts to profile updates via _profileUpdateTriggerProvider
|
||||
/// - Returns local data immediately if available
|
||||
/// - Reacts to profile updates from batch fetches via trigger mechanism
|
||||
/// - Streams updates when profile data changes
|
||||
final profileProvider =
|
||||
AutoDisposeProvider.family<AsyncValue<Profile?>, String>((ref, pubkey) {
|
||||
final lastFetchTime = ref.watch(
|
||||
_profileFetchTimesProvider.select((state) => state[pubkey]),
|
||||
);
|
||||
// Watch update trigger to react when profiles are saved by fetchProfiles()
|
||||
ref.watch(_profileUpdateTriggerProvider.select((state) => state[pubkey]));
|
||||
|
||||
// Watch update trigger to react when profiles are saved
|
||||
ref.watch(
|
||||
_profileUpdateTriggerProvider.select((state) => state[pubkey]),
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
final shouldFetchRemote =
|
||||
lastFetchTime == null ||
|
||||
now.difference(lastFetchTime) > kProfileCacheDuration;
|
||||
|
||||
// Trigger background fetch if cache is stale
|
||||
if (shouldFetchRemote) {
|
||||
ref.watch(_profileRemoteFetchProvider(pubkey));
|
||||
}
|
||||
|
||||
// Read from local storage (will re-run when update trigger changes)
|
||||
// Use query with LocalAndRemoteSource for reactive updates
|
||||
final profileState = ref.watch(
|
||||
query<Profile>(authors: {pubkey}, source: const LocalSource()),
|
||||
query<Profile>(
|
||||
authors: {pubkey},
|
||||
source: LocalAndRemoteSource(
|
||||
relays: {'social', 'vertex'},
|
||||
stream: true,
|
||||
background: true, // Don't block on remote
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return switch (profileState) {
|
||||
|
||||
@@ -174,42 +174,8 @@ final categorizedAppsProvider =
|
||||
);
|
||||
|
||||
/// Provider that calculates the total number of apps with available updates
|
||||
/// Uses LocalSource since categorizedAppsProvider already fetches from relays
|
||||
final updateCountProvider = Provider<int>((ref) {
|
||||
// Watch package manager for installed state changes
|
||||
final packages = ref.watch(packageManagerProvider);
|
||||
final installedIds = packages.map((p) => p.appId).toSet();
|
||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||
|
||||
if (installedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Query apps from local storage only - data is already fetched by categorizedAppsProvider
|
||||
final state = ref.watch(
|
||||
query<App>(
|
||||
tags: {
|
||||
'#d': installedIds,
|
||||
'#f': {platform},
|
||||
},
|
||||
and: (app) => {
|
||||
app.latestRelease,
|
||||
// Load nested FileMetadata - critical for hasUpdate to work
|
||||
if (app.latestRelease.value != null)
|
||||
app.latestRelease.value!.latestMetadata,
|
||||
},
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'update-count',
|
||||
),
|
||||
);
|
||||
|
||||
int updateCount = 0;
|
||||
|
||||
for (final app in state.models) {
|
||||
if (app.hasUpdate) {
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return updateCount;
|
||||
final categorized = ref.watch(categorizedAppsProvider);
|
||||
if (categorized.isLoading) return 0;
|
||||
return categorized.automaticUpdates.length + categorized.manualUpdates.length;
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/widgets/profiles_rich_text.dart';
|
||||
|
||||
class AppPickersContainer extends StatelessWidget {
|
||||
const AppPickersContainer({super.key, required this.app, this.author});
|
||||
|
||||
final App app;
|
||||
final Profile? author;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appPacks = app.appPacks.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (appPacks.isNotEmpty) ...[
|
||||
_AppPackPickers(appPacks: appPacks),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppPackPickers extends StatelessWidget {
|
||||
const _AppPackPickers({required this.appPacks});
|
||||
|
||||
final List<AppPack> appPacks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final seenPubkeys = <String>{};
|
||||
final curatorProfiles = appPacks
|
||||
.map((pack) => pack.author.value)
|
||||
.where((profile) => profile != null && seenPubkeys.add(profile.pubkey))
|
||||
.cast<Profile>()
|
||||
.toList();
|
||||
|
||||
if (curatorProfiles.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ProfilesRichText(
|
||||
profiles: curatorProfiles,
|
||||
trailingText: ' picked this app',
|
||||
maxProfilesToDisplay: 3,
|
||||
avatarRadius: 12,
|
||||
textStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,15 @@ class AppPackContainer extends HookConsumerWidget {
|
||||
// Show newest packs first (also drives default selection)
|
||||
final sortedPacks = _sortPacksNewestFirst(nonEmptyPacks);
|
||||
|
||||
// Fetch author profiles for all app packs (batch fetch like latest_releases)
|
||||
useEffect(() {
|
||||
final authorPubkeys = sortedPacks.map((p) => p.event.pubkey).toSet();
|
||||
if (authorPubkeys.isNotEmpty) {
|
||||
ref.read(profileServiceProvider).fetchProfiles(authorPubkeys);
|
||||
}
|
||||
return null;
|
||||
}, [sortedPacks.map((p) => p.id).join(',')]);
|
||||
|
||||
// Default to the newest available pack
|
||||
final defaultSelection = sortedPacks.first.id;
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ import 'package:zapstore/widgets/common/profile_avatar.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:zapstore/widgets/sign_in_button.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/services/profile_service.dart';
|
||||
import 'package:zapstore/widgets/pill_widget.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
|
||||
class CommentsSection extends ConsumerWidget {
|
||||
class CommentsSection extends HookConsumerWidget {
|
||||
const CommentsSection({super.key, this.fileMetadata});
|
||||
|
||||
final FileMetadata? fileMetadata;
|
||||
@@ -51,6 +52,15 @@ class CommentsSection extends ConsumerWidget {
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Batch fetch author profiles for all comments
|
||||
useEffect(() {
|
||||
final authorPubkeys = comments.map((c) => c.event.pubkey).toSet();
|
||||
if (authorPubkeys.isNotEmpty) {
|
||||
ref.read(profileServiceProvider).fetchProfiles(authorPubkeys);
|
||||
}
|
||||
return null;
|
||||
}, [comments.map((c) => c.id).join(',')]);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -155,7 +165,9 @@ class _CommentCard extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final author = comment.author.value;
|
||||
// Use profileProvider for centralized caching and batched fetching
|
||||
final authorAsync = ref.watch(profileProvider(comment.event.pubkey));
|
||||
final author = authorAsync.value ?? comment.author.value;
|
||||
// Extract version from v tag (per NIP-22 guidance)
|
||||
final version = comment.event.getFirstTagValue('v');
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import '../../../theme.dart';
|
||||
import 'package:zapstore/services/profile_service.dart';
|
||||
import 'package:zapstore/widgets/common/time_utils.dart';
|
||||
import 'package:zapstore/widgets/common/profile_avatar.dart';
|
||||
import 'package:any_link_preview/any_link_preview.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -365,8 +361,6 @@ class NostrEntityWidget extends StatelessWidget {
|
||||
EventData() => EventEntityWidget(
|
||||
eventData: decoded,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
onHashtagTap: onHashtagTap,
|
||||
),
|
||||
AddressData() => AddressEntityWidget(
|
||||
addressData: decoded,
|
||||
@@ -436,136 +430,21 @@ class ProfileEntityWidget extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class EventEntityWidget extends ConsumerWidget {
|
||||
class EventEntityWidget extends StatelessWidget {
|
||||
final EventData eventData;
|
||||
final List<Color> colorPair;
|
||||
final void Function(String pubkey)? onProfileTap;
|
||||
final void Function(String hashtag)? onHashtagTap;
|
||||
|
||||
const EventEntityWidget({
|
||||
super.key,
|
||||
required this.eventData,
|
||||
required this.colorPair,
|
||||
this.onProfileTap,
|
||||
this.onHashtagTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final noteState = ref.watch(
|
||||
query<Note>(
|
||||
ids: {eventData.eventId},
|
||||
limit: 1,
|
||||
and: (note) => {note.author},
|
||||
// Profile relationships should come from social relays
|
||||
andSource: const LocalAndRemoteSource(relays: 'social'),
|
||||
subscriptionPrefix: 'entity-note',
|
||||
),
|
||||
);
|
||||
|
||||
final note = noteState.models.firstOrNull;
|
||||
|
||||
if (note == null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
border: Border.all(
|
||||
color: colorPair[0].withValues(alpha: 0.2),
|
||||
width: 1.0,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorPair[0].withValues(alpha: 0.05),
|
||||
blurRadius: 4.0,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Skeleton for author info
|
||||
SkeletonizerConfig(
|
||||
data: AppColors.getSkeletonizerConfig(
|
||||
Theme.of(context).brightness,
|
||||
),
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 13,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 11,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
|
||||
// Skeleton for note content
|
||||
SkeletonizerConfig(
|
||||
data: AppColors.getSkeletonizerConfig(
|
||||
Theme.of(context).brightness,
|
||||
),
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width * 0.7,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget build(BuildContext context) {
|
||||
final shortId = eventData.eventId.length > 12
|
||||
? '${eventData.eventId.substring(0, 8)}…'
|
||||
: eventData.eventId;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
@@ -586,52 +465,34 @@ class EventEntityWidget extends ConsumerWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onProfileTap != null
|
||||
? () => onProfileTap!(note.event.pubkey)
|
||||
: null,
|
||||
child: Row(
|
||||
Icon(Icons.event, color: colorPair[0]),
|
||||
const SizedBox(width: 8.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ProfileAvatar(
|
||||
profile: note.author.value,
|
||||
radius: 12,
|
||||
borderColors: colorPair,
|
||||
Text(
|
||||
'Event',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
Expanded(
|
||||
child: note.author.value?.nameOrNpub != null
|
||||
? Text(
|
||||
note.author.value!.nameOrNpub,
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
)
|
||||
: _AnimatedLoadingChip(
|
||||
text:
|
||||
'npub1${note.event.pubkey.substring(0, 8)}...',
|
||||
colorPair: colorPair,
|
||||
),
|
||||
),
|
||||
TimeAgoText(
|
||||
note.createdAt,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(fontSize: 11),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
shortId,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.7),
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
ParsedContentWidget(
|
||||
note: note,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
onHashtagTap: onHashtagTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -706,282 +567,6 @@ class GenericNip19Widget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ParsedContentWidget extends ConsumerWidget {
|
||||
final Note note;
|
||||
final List<Color> colorPair;
|
||||
final void Function(String pubkey)? onProfileTap;
|
||||
final void Function(String hashtag)? onHashtagTap;
|
||||
|
||||
const ParsedContentWidget({
|
||||
super.key,
|
||||
required this.note,
|
||||
required this.colorPair,
|
||||
this.onProfileTap,
|
||||
this.onHashtagTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Reply context - show if this note is a reply
|
||||
if (note.replyTo.value != null) ...[
|
||||
ReplyContextWidget(
|
||||
replyingNote: note,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
onHashtagTap: onHashtagTap,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Original note content
|
||||
_buildNoteContent(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoteContent(BuildContext context) {
|
||||
if (note.content.trim().isEmpty) {
|
||||
return Text(
|
||||
'No content',
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return NoteParser.parse(
|
||||
context,
|
||||
note.content,
|
||||
textStyle: context.textTheme.bodyMedium,
|
||||
linkStyle: context.textTheme.bodyMedium!.copyWith(
|
||||
color: colorPair[0],
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
onNostrEntity: (entity) => NostrEntityWidget(
|
||||
entity: entity,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
onHashtagTap: onHashtagTap,
|
||||
),
|
||||
onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair),
|
||||
onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair),
|
||||
onHashtagTap: onHashtagTap,
|
||||
onProfileTap: onProfileTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal widget for reply context - now part of the note parser
|
||||
class ReplyContextWidget extends ConsumerWidget {
|
||||
final Note replyingNote;
|
||||
final List<Color> colorPair;
|
||||
final void Function(String pubkey)? onProfileTap;
|
||||
final void Function(String hashtag)? onHashtagTap;
|
||||
|
||||
const ReplyContextWidget({
|
||||
super.key,
|
||||
required this.replyingNote,
|
||||
required this.colorPair,
|
||||
this.onProfileTap,
|
||||
this.onHashtagTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final replyToNote = replyingNote.replyTo.value;
|
||||
|
||||
if (replyToNote == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Load the author for the replyTo note
|
||||
final authorAsync = ref.watch(profileProvider(replyToNote.event.pubkey));
|
||||
final author = authorAsync.value;
|
||||
final isAuthorLoading = authorAsync.isLoading || !authorAsync.hasValue;
|
||||
|
||||
// Check if the reply-to note itself is still being loaded
|
||||
final replyToNoteState = ref.watch(
|
||||
query<Note>(
|
||||
ids: {replyToNote.event.id},
|
||||
limit: 1,
|
||||
subscriptionPrefix: 'reply-note',
|
||||
),
|
||||
);
|
||||
|
||||
final isNoteLoading = replyToNoteState is StorageLoading;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
border: Border.all(
|
||||
color: colorPair[0].withValues(alpha: 0.2),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// "Replying to" indicator
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.reply,
|
||||
size: 14,
|
||||
color: colorPair[0].withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Replying to',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: colorPair[0].withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Parent note preview
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Parent note author
|
||||
GestureDetector(
|
||||
onTap: onProfileTap != null
|
||||
? () => onProfileTap!(replyToNote.event.pubkey)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
ProfileAvatar(
|
||||
profile: author,
|
||||
radius: 10,
|
||||
borderColors: colorPair,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: isAuthorLoading || isNoteLoading
|
||||
? _AnimatedLoadingChip(
|
||||
text:
|
||||
'npub1${replyToNote.event.pubkey.substring(0, 8)}...',
|
||||
colorPair: colorPair,
|
||||
)
|
||||
: Text(
|
||||
author?.nameOrNpub ??
|
||||
'${replyToNote.event.pubkey.substring(0, 8)}...',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TimeAgoText(
|
||||
replyToNote.createdAt,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Parent note content with proper loading state handling
|
||||
isNoteLoading || isAuthorLoading
|
||||
? Skeletonizer(
|
||||
enabled: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: replyToNote.content.trim().isEmpty
|
||||
? Text(
|
||||
'No content',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: _buildFullContent(context, replyToNote.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullContent(BuildContext context, String content) {
|
||||
return NoteParser.parse(
|
||||
context,
|
||||
content,
|
||||
textStyle: context.textTheme.bodySmall?.copyWith(fontSize: 12),
|
||||
linkStyle: context.textTheme.bodySmall?.copyWith(
|
||||
color: colorPair[0],
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
onNostrEntity: (entity) => NostrEntityWidget(
|
||||
entity: entity,
|
||||
colorPair: colorPair,
|
||||
onProfileTap: onProfileTap,
|
||||
onHashtagTap: onHashtagTap,
|
||||
),
|
||||
onHttpUrl: (url) => UrlChipWidget(url: url, colorPair: colorPair),
|
||||
onMediaUrl: (url) => MediaWidget(url: url, colorPair: colorPair),
|
||||
onHashtagTap: onHashtagTap,
|
||||
onProfileTap: onProfileTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UrlChipWidget extends StatelessWidget {
|
||||
final String url;
|
||||
final List<Color> colorPair;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
/// Date pill widget showing a formatted date in pill format
|
||||
class DatePillWidget extends StatelessWidget {
|
||||
final DateTime date;
|
||||
|
||||
const DatePillWidget({super.key, required this.date});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formattedDate = DateFormat('MMM d, y').format(date);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
formattedDate,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/profile_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
@@ -17,7 +18,9 @@ class InstallAlertDialog extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final publisher = app.author.value;
|
||||
// Use profileProvider with fallback to relationship
|
||||
final publisherAsync = ref.watch(profileProvider(app.pubkey));
|
||||
final publisher = publisherAsync.value ?? app.author.value;
|
||||
if (publisher == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -268,8 +268,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
and: (app) => {
|
||||
app.latestRelease,
|
||||
// Load nested FileMetadata from latestRelease (same relay group)
|
||||
if (app.latestRelease.value != null)
|
||||
app.latestRelease.value!.latestMetadata,
|
||||
app.latestRelease.value?.latestMetadata,
|
||||
},
|
||||
// NOTE: It must stream=true
|
||||
source: const LocalAndRemoteSource(
|
||||
@@ -291,14 +290,6 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
.where((a) => !liveIds.contains(a.id))
|
||||
.toList();
|
||||
state = state.copyWith(storage: next, olderApps: filteredOlder);
|
||||
|
||||
final newApps = next.models;
|
||||
|
||||
// For new apps, one-off fetch their relationships
|
||||
// TODO: Not needed - should be dealt with by and/andSource
|
||||
if (newApps.isNotEmpty) {
|
||||
await _fetchRelationshipsForNewApps(newApps);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(storage: next);
|
||||
}
|
||||
@@ -311,41 +302,6 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// One-off fetch of Release and FileMetadata for newly received apps
|
||||
Future<void> _fetchRelationshipsForNewApps(List<App> newApps) async {
|
||||
if (newApps.isEmpty) return;
|
||||
|
||||
// One-off fetch: Get releases for these apps from remote
|
||||
final releases = await ref.storage.query(
|
||||
Request<Release>(
|
||||
newApps
|
||||
.map((app) => app.latestRelease.req?.filters.firstOrNull)
|
||||
.nonNulls
|
||||
.toList(),
|
||||
),
|
||||
source: const RemoteSource(stream: false),
|
||||
);
|
||||
|
||||
// One-off fetch: Get file metadata for the releases from remote
|
||||
if (releases.isNotEmpty) {
|
||||
await ref.storage.query(
|
||||
Request<FileMetadata>(
|
||||
releases
|
||||
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
|
||||
.nonNulls
|
||||
.toList(),
|
||||
),
|
||||
source: const RemoteSource(stream: false),
|
||||
);
|
||||
}
|
||||
|
||||
// One-off fetch: Get release author profiles from 'social' relay group
|
||||
final authorPubkeys = releases.map((r) => r.event.pubkey).toSet();
|
||||
if (authorPubkeys.isNotEmpty) {
|
||||
await ref.read(profileServiceProvider).fetchProfiles(authorPubkeys);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch authors for a page of apps (used during pagination)
|
||||
Future<void> _loadRelationshipsFor(List<App> appsPage) async {
|
||||
final releases = appsPage
|
||||
|
||||
Reference in New Issue
Block a user