Improve trust dialog, add zaps

This commit is contained in:
franzap
2025-12-23 11:57:57 -03:00
parent 5b49b75172
commit 48bac20f63
2 changed files with 304 additions and 107 deletions
+29 -21
View File
@@ -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<Profile>(
authors: {app.pubkey},
source: const LocalAndRemoteSource(relays: {'social', 'vertex'}, cachedFor: Duration(hours: 2)),
));
final publisherState = ref.watch(
query<Profile>(
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: [
+275 -86
View File
@@ -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<Profile>(authors: {app.pubkey}, source: const LocalSource()),
);
final author = authorState.models.firstOrNull;
Widget buildBody(bool userFollowsTarget) => switch (data) {
AsyncData<List<Profile>>(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<Zap>(
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<Zap>(
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<String>? followingPubkeys,
}) {
// Extract zap author profiles
// 1. Check if signed-in user zapped
// 2. Get zappers that the user follows
bool userZapped = false;
final followedZappers = <Profile>[];
final seenPubkeys = <String>{};
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<List<Profile>>(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<ContactList>(
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<Profile> followedZappers,
}) {
final targetNpub = author.npub;
final avatarSize = (size ?? 14) * 1.4;
final hasZapInfo = userZapped || followedZappers.isNotEmpty;
final spans = <InlineSpan>[
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');
});