diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index 6411740..362b137 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -18,10 +18,15 @@ class InstallAlertDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Query publisher profile (app.pubkey is always present) - final publisherState = ref.watch(query( - authors: {app.pubkey}, - source: const LocalAndRemoteSource(relays: {'social', 'vertex'}, cachedFor: Duration(hours: 2)), - )); + final publisherState = ref.watch( + query( + authors: {app.pubkey}, + source: const LocalAndRemoteSource( + relays: {'social', 'vertex'}, + cachedFor: Duration(hours: 2), + ), + ), + ); final publisher = switch (publisherState) { StorageData(:final models) => models.firstOrNull, _ => null, @@ -57,28 +62,31 @@ class InstallAlertDialog extends HookConsumerWidget { size: baseTextSize, ), ] else ...[ - AuthorContainer( - profile: publisher, - beforeText: '${app.name ?? app.identifier} is published by', - afterText: '.', - oneLine: false, - size: baseTextSize, - ), - Gap(14), profile != null ? RelevantWhoFollowContainer( - toNpub: publisher.npub, - trailingText: - ' and others follow ${publisher.nameOrNpub} on Nostr.\n\nThis information helps you determine ${publisher.nameOrNpub}\'s reputation. They are not endorsements of the ${app.name} app.', + app: app, size: baseTextSize, ) - : SignInButton( - label: - 'Sign in to view the publisher\'s reputable followers', - minimal: true, - requireNip55: true, + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AuthorContainer( + profile: publisher, + beforeText: '${app.name ?? app.identifier} is published by', + afterText: '.', + oneLine: false, + size: baseTextSize, + ), + const Gap(14), + SignInButton( + label: + 'Sign in to view the publisher\'s reputable followers', + minimal: true, + requireNip55: true, + ), + ], ), - Gap(14), + const Gap(14), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ diff --git a/lib/widgets/relevant_who_follow_container.dart b/lib/widgets/relevant_who_follow_container.dart index 9ffb6d5..359d2ca 100644 --- a/lib/widgets/relevant_who_follow_container.dart +++ b/lib/widgets/relevant_who_follow_container.dart @@ -5,20 +5,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:zapstore/widgets/profiles_rich_text.dart'; class RelevantWhoFollowContainer extends HookConsumerWidget { const RelevantWhoFollowContainer({ super.key, - required this.toNpub, + required this.app, this.loadingText = 'Checking reputation...', - this.trailingText = '', this.size, }); final String loadingText; - final String trailingText; - final String toNpub; + final App app; final double? size; @override @@ -35,74 +34,20 @@ class RelevantWhoFollowContainer extends HookConsumerWidget { context, ).colorScheme.onSurface.withValues(alpha: 0.7), )); + final boldStyle = baseStyle?.copyWith(fontWeight: FontWeight.w600); - // Get signed-in user's pubkey and contact list + // Get signed-in user's pubkey final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final data = ref.watch(relevantWhoFollowProvider((to: toNpub))); + // Query author profile from local storage (should already be preloaded) + final authorState = ref.watch( + query(authors: {app.pubkey}, source: const LocalSource()), + ); + final author = authorState.models.firstOrNull; - Widget buildBody(bool userFollowsTarget) => switch (data) { - AsyncData>(value: final users) => Builder( - builder: (context) { - // Find relevant who follow, remove the target that comes included - final relevantWhoFollow = users - .where((u) => u.pubkey.encodeShareable(type: 'npub') != toNpub) - .toList(); - - if (relevantWhoFollow.isEmpty && !userFollowsTarget) { - return Text( - 'No reputation information found for this publisher.', - style: baseStyle, - ); - } - - // If user follows but no Vertex followers, show a simple message - if (relevantWhoFollow.isEmpty && userFollowsTarget) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich( - TextSpan( - text: 'You follow this publisher. ', - style: baseStyle, - children: [TextSpan(text: trailingText)], - ), - ), - const Gap(8), - _buildViewProfileLink(context, toNpub, baseStyle), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!userFollowsTarget) ...[ - Text('You are not following them.', style: baseStyle), - const Gap(8), - ], - ProfilesRichText( - profiles: relevantWhoFollow, - leadingText: userFollowsTarget ? 'You, ' : null, - commasOnly: true, - trailingText: trailingText, - textStyle: baseStyle, - avatarRadius: 10, - ), - const Gap(8), - _buildViewProfileLink(context, toNpub, baseStyle), - ], - ); - }, - ), - AsyncError() => Center( - child: Text( - 'Reputation service is unreachable. Try again later.', - style: baseStyle, - ), - ), - // Loading state - _ => Center( + // If author not loaded yet, show loading + if (author == null) { + return Center( child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -118,10 +63,165 @@ class RelevantWhoFollowContainer extends HookConsumerWidget { ], ), ), - ), - }; + ); + } - if (signedInPubkey == null) return buildBody(false); + final targetNpub = author.npub; + + // Query preloaded zaps for the app (LocalSource only) + final zapsState = ref.watch( + query( + tags: app.event.addressableIdTagMap, + source: const LocalSource(), + and: (zap) => {zap.author, zap.zapRequest}, + subscriptionPrefix: 'trust-zaps', + ), + ); + + // Also query zaps on the latest metadata if available + final latestMetadata = app.latestFileMetadata; + final metadataZapsState = latestMetadata != null + ? ref.watch( + query( + tags: { + '#e': {latestMetadata.id}, + }, + source: const LocalSource(), + and: (zap) => {zap.author, zap.zapRequest}, + subscriptionPrefix: 'trust-metadata-zaps', + ), + ) + : null; + + // Combine all zaps + final allZaps = {...zapsState.models, ...?metadataZapsState?.models}; + + final data = ref.watch(relevantWhoFollowProvider((to: targetNpub))); + + Widget buildBody({ + required bool userFollowsTarget, + required Set? followingPubkeys, + }) { + // Extract zap author profiles + // 1. Check if signed-in user zapped + // 2. Get zappers that the user follows + bool userZapped = false; + final followedZappers = []; + final seenPubkeys = {}; + + if (allZaps.isNotEmpty) { + for (final zap in allZaps) { + final requestAuthor = zap.zapRequest.value?.author.value; + final walletAuthor = zap.author.value; + final chosenAuthor = requestAuthor ?? walletAuthor; + if (chosenAuthor == null) continue; + + final pubkey = chosenAuthor.pubkey; + + // Check if signed-in user zapped + if (signedInPubkey != null && pubkey == signedInPubkey) { + userZapped = true; + continue; // Don't add to followedZappers list + } + + // Add followed zappers (exclude duplicates) + if (followingPubkeys != null && + followingPubkeys.contains(pubkey) && + !seenPubkeys.contains(pubkey)) { + seenPubkeys.add(pubkey); + followedZappers.add(chosenAuthor); + } + } + } + + return switch (data) { + AsyncData>(value: final users) => Builder( + builder: (context) { + // Find relevant who follow, remove the target that comes included + final relevantWhoFollow = users + .take(5) + .where( + (u) => u.pubkey.encodeShareable(type: 'npub') != targetNpub, + ) + .toList(); + + if (relevantWhoFollow.isEmpty && + !userFollowsTarget && + followedZappers.isEmpty) { + return Text( + 'No reputation information found for this publisher.', + style: baseStyle, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main sentence: "(App) is published by (profile) (view profile link) and zapped by ..." + _buildPublishedBySection( + context, + author, + baseStyle, + boldStyle, + userZapped: userZapped, + followedZappers: followedZappers.take(4).toList(), + ), + const Gap(16), + // Reputation section + if (relevantWhoFollow.isNotEmpty || userFollowsTarget) ...[ + ProfilesRichText( + profiles: relevantWhoFollow, + leadingText: userFollowsTarget ? 'You, ' : null, + commasOnly: true, + trailingText: + ' and others follow ${author.nameOrNpub} on Nostr.', + textStyle: baseStyle, + avatarRadius: 10, + ), + const Gap(8), + ] else if (!userFollowsTarget) ...[ + Text('You are not following them.', style: baseStyle), + const Gap(8), + ], + // Disclaimer + Text( + 'These are not endorsements of the ${app.name} app.', + style: baseStyle?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ); + }, + ), + AsyncError(:final error) => Center( + child: Text( + error.toString().replaceFirst('Exception: ', ''), + style: baseStyle, + ), + ), + // Loading state + _ => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(loadingText, style: baseStyle), + const Gap(8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ], + ), + ), + ), + }; + } + + if (signedInPubkey == null) { + return buildBody(userFollowsTarget: false, followingPubkeys: null); + } return Consumer( builder: (context, ref, _) { @@ -129,29 +229,54 @@ class RelevantWhoFollowContainer extends HookConsumerWidget { query( authors: {signedInPubkey}, limit: 1, - source: LocalAndRemoteSource(relays: 'social', stream: false), + source: const LocalAndRemoteSource(relays: 'social', stream: false), subscriptionPrefix: 'user-contacts', ), ); - final targetPubkey = toNpub.decodeShareable(); + final targetPubkey = targetNpub.decodeShareable(); final contactList = contactListState.models.firstOrNull; + final followingPubkeys = contactList?.followingPubkeys; final userFollowsTarget = - contactList?.followingPubkeys.contains(targetPubkey) ?? false; + followingPubkeys?.contains(targetPubkey) ?? false; - return buildBody(userFollowsTarget); + return buildBody( + userFollowsTarget: userFollowsTarget, + followingPubkeys: followingPubkeys, + ); }, ); } - Widget _buildViewProfileLink( + Widget _buildPublishedBySection( BuildContext context, - String npub, + Profile author, TextStyle? baseStyle, - ) { - return Text.rich( + TextStyle? boldStyle, { + required bool userZapped, + required List followedZappers, + }) { + final targetNpub = author.npub; + final avatarSize = (size ?? 14) * 1.4; + final hasZapInfo = userZapped || followedZappers.isNotEmpty; + + final spans = [ + TextSpan(text: '${app.name} is published by ', style: baseStyle), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: ProfileAvatar(profile: author, radius: avatarSize / 2), + ), + ), + ), + TextSpan(text: author.nameOrNpub, style: boldStyle), + TextSpan(text: ' (', style: baseStyle), TextSpan( - text: 'View profile on nostr', + text: 'view profile on Nostr', style: baseStyle?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, @@ -159,13 +284,69 @@ class RelevantWhoFollowContainer extends HookConsumerWidget { ), recognizer: TapGestureRecognizer() ..onTap = () async { - final url = Uri.parse('https://npub.world/$npub'); + final url = Uri.parse('https://npub.world/$targetNpub'); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } }, ), - ); + TextSpan(text: ')', style: baseStyle), + ]; + + // Add zap info if present: " and was zapped by you and (z1)" + if (hasZapInfo) { + spans.add(TextSpan(text: ' and was zapped by ', style: baseStyle)); + + // Add "you" if user zapped + if (userZapped) { + spans.add(TextSpan(text: 'you', style: boldStyle)); + // Add separator: "and" if only 1 more, ", " if more than 1 more + if (followedZappers.length == 1) { + spans.add(TextSpan(text: ' and ', style: baseStyle)); + } else if (followedZappers.length > 1) { + spans.add(TextSpan(text: ', ', style: baseStyle)); + } + } + + // Add followed zappers with avatars + for (int i = 0; i < followedZappers.length; i++) { + final zapper = followedZappers[i]; + final isLast = i == followedZappers.length - 1; + final isSecondToLast = i == followedZappers.length - 2; + + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: SizedBox( + width: avatarSize, + height: avatarSize, + child: ProfileAvatar(profile: zapper, radius: avatarSize / 2), + ), + ), + ), + ); + spans.add(TextSpan(text: zapper.nameOrNpub, style: boldStyle)); + + // Add separator + if (!isLast) { + if (isSecondToLast && (!userZapped || followedZappers.length == 1)) { + // "and" before last item when no user prefix or only 2 total + spans.add(TextSpan(text: ' and ', style: baseStyle)); + } else if (isSecondToLast) { + // ", and" (Oxford comma) before last when user + multiple + spans.add(TextSpan(text: ', and ', style: baseStyle)); + } else { + spans.add(TextSpan(text: ', ', style: baseStyle)); + } + } + } + } + + spans.add(TextSpan(text: '.', style: baseStyle)); + + return Text.rich(TextSpan(children: spans)); } } @@ -187,9 +368,7 @@ final relevantWhoFollowProvider = FutureProvider.autoDispose final request = await partial.signWith(signer); // Execute against the 'vertex' relay group and await response - final response = await request - .run('vertex') - .timeout(Duration(seconds: 6)); + final response = await request.run('vertex'); if (response is VerifyReputationResponse) { // Fetch corresponding profiles for the returned pubkeys @@ -201,5 +380,15 @@ final relevantWhoFollowProvider = FutureProvider.autoDispose return profiles; } - throw Exception('Bad response from service'); + // Handle DVM error responses + if (response is DVMError) { + throw Exception(response.status ?? 'DVM service error'); + } + + // Handle null (no response received - timeout or connection issue) + if (response == null) { + throw Exception('No response from reputation service'); + } + + throw Exception('Unexpected response from service'); });