mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Improve trust dialog, add zaps
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user