From b834816a57eb7e41466c735d31a26fff4181f9eb Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 7 Jan 2025 17:41:18 -0300 Subject: [PATCH] Fix zaps caching, loading, display and order by amount --- lib/models/user.dart | 57 +++++----- lib/models/zap_receipt.dart | 7 +- lib/navigation/app_initializer.dart | 2 + lib/screens/app_detail_screen.dart | 5 +- lib/utils/signers.dart | 2 +- lib/widgets/app_card.dart | 2 +- lib/widgets/app_curation_container.dart | 2 +- lib/widgets/pill_widget.dart | 7 +- lib/widgets/users_rich_text.dart | 13 ++- lib/widgets/version_pill_widget.dart | 4 +- lib/widgets/zap_receipts.dart | 133 ++++++++++++++++++++---- 11 files changed, 162 insertions(+), 72 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index fe076fb..e4ce3fa 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -18,6 +18,35 @@ class User extends base.User with DataModelMixin { @override Object? get id => event.id; + User.fromJson(super.map) + : followers = hasMany(map['followers']), + following = hasMany(map['following']), + settings = belongsTo(map['settings']), + super.fromJson(); + + User.fromPubkey(String pubkey) + : this.fromJson({ + 'id': pubkey, + 'kind': 0, + 'pubkey': pubkey.hexKey, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'content': '{}', + 'tags': [], + }); + + Map toJson() => super.toMap(); + + @DataRelationship(inverse: 'followers') + final HasMany following; + @DataRelationship(inverse: 'following') + final HasMany followers; + @DataRelationship(inverse: 'user') + final BelongsTo settings; + + String get nameOrNpub => name ?? npub.shorten; + + // Zaps + Future zap(int amountInSats, {required base.Event event, String? comment, base.Signer? signer}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; @@ -87,6 +116,7 @@ class User extends base.User with DataModelMixin { } // Pay the invoice via NWC (errors are thrown) + // TODO: If timeout is thrown, tell the user the invoice still may get paid await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, invoice: invoice, timeout: Duration(seconds: 20)); } @@ -98,33 +128,6 @@ class User extends base.User with DataModelMixin { final map = jsonDecode(response.body); return map; } - - User.fromJson(super.map) - : followers = hasMany(map['followers']), - following = hasMany(map['following']), - settings = belongsTo(map['settings']), - super.fromJson(); - - User.fromPubkey(String pubkey) - : this.fromJson({ - 'id': pubkey, - 'kind': 0, - 'pubkey': pubkey.hexKey, - 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, - 'content': '{}', - 'tags': [], - }); - - Map toJson() => super.toMap(); - - @DataRelationship(inverse: 'followers') - final HasMany following; - @DataRelationship(inverse: 'following') - final HasMany followers; - @DataRelationship(inverse: 'user') - final BelongsTo settings; - - String get nameOrNpub => name ?? npub.shorten; } mixin UserAdapter on NostrAdapter { diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart index 746d777..7348254 100644 --- a/lib/models/zap_receipt.dart +++ b/lib/models/zap_receipt.dart @@ -33,11 +33,10 @@ mixin ZapReceiptAdapter on Adapter { return super.deserialize(data); } - List findByRecipient( - {required String pubkey, required String eventId}) { + List findByEventId(String eventId) { final result = db.select( - "SELECT z.key, value from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'p' AND json_extract(value, '\$[1]') = ?AND json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?", - [pubkey, eventId]); + "SELECT z.key, z.data from zapReceipts z, json_each(json_extract(data, '\$.tags')) WHERE json_extract(value, '\$[0]') = 'e' AND json_extract(value, '\$[1]') = ?", + [eventId]); return deserializeFromResult(result); } } diff --git a/lib/navigation/app_initializer.dart b/lib/navigation/app_initializer.dart index 875abad..7fdbaa2 100644 --- a/lib/navigation/app_initializer.dart +++ b/lib/navigation/app_initializer.dart @@ -72,6 +72,8 @@ final appInitializer = FutureProvider((ref) async { if (uri.scheme == 'https' && uri.hasFragment) { appId = uri.fragment; } else if (uri.scheme == 'zapstore') { + // zapstore://com.app.id + // zapstore://com.app.id?signer=npub1xyz appId = uri.host; } if (appId != null) { diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 13c4e18..001943f 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -18,7 +18,6 @@ import 'package:zapstore/widgets/signer_and_developer_row.dart'; import 'package:zapstore/widgets/spinning_logo.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; -import 'package:zapstore/widgets/zap_button.dart'; import 'package:zapstore/widgets/zap_receipts.dart'; class AppDetailScreen extends HookConsumerWidget { @@ -157,10 +156,8 @@ class AppDetailScreen extends HookConsumerWidget { padding: const EdgeInsets.only(right: 14), child: SignerAndDeveloperRow(app: app), ), - Gap(10), - ZapButton(app: app), ZapReceipts(app: app), - Gap(10), + Gap(20), Container( padding: EdgeInsets.all(10), decoration: BoxDecoration( diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart index e7e6cc1..359cffe 100644 --- a/lib/utils/signers.dart +++ b/lib/utils/signers.dart @@ -21,7 +21,6 @@ class AmberSigner extends Signer { @override Future getPublicKey() async { final map = await _signerPlugin.getPublicKey(); - print('got $map'); return map['npub'] ?? map['result']; } @@ -39,6 +38,7 @@ class AmberSigner extends Signer { withPubkey!.npub, (partialEvent as DirectMessage).receiver.hexKey); final encryptedContent = signedMessage['result']; + // TODO: Check this is actually encrypting the content? partialEvent.event.content = encryptedContent; } diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index c4c6d1a..808b008 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -230,7 +230,7 @@ class TagsContainer extends StatelessWidget { for (final tag in tags) Padding( padding: const EdgeInsets.only(right: 6), - child: PillWidget(text: TextSpan(text: tag)), + child: PillWidget(TextSpan(text: tag)), ), ], ); diff --git a/lib/widgets/app_curation_container.dart b/lib/widgets/app_curation_container.dart index 6bd4b11..1ab5457 100644 --- a/lib/widgets/app_curation_container.dart +++ b/lib/widgets/app_curation_container.dart @@ -47,7 +47,7 @@ class AppCurationContainer extends HookConsumerWidget { onTap: () => ref.read(_selectedIdProvider.notifier).state = appCurationSet.getReplaceableEventLink(), child: PillWidget( - text: WidgetSpan( + WidgetSpan( alignment: PlaceholderAlignment.middle, child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, diff --git a/lib/widgets/pill_widget.dart b/lib/widgets/pill_widget.dart index ff872f7..f686cac 100644 --- a/lib/widgets/pill_widget.dart +++ b/lib/widgets/pill_widget.dart @@ -4,11 +4,8 @@ class PillWidget extends StatelessWidget { final InlineSpan text; final Color color; final double size; - const PillWidget( - {super.key, - required this.text, - this.color = Colors.blue, - this.size = 13}); + const PillWidget(this.text, + {super.key, this.color = Colors.blue, this.size = 13}); @override Widget build(BuildContext context) { diff --git a/lib/widgets/users_rich_text.dart b/lib/widgets/users_rich_text.dart index e974e56..db9df8c 100644 --- a/lib/widgets/users_rich_text.dart +++ b/lib/widgets/users_rich_text.dart @@ -56,9 +56,11 @@ class UsersRichText extends StatelessWidget { padding: const EdgeInsets.only(right: 4), child: RoundedImage(url: user.avatarUrl, size: 20), ), - Text(user.nameOrNpub, - style: TextStyle( - fontSize: fontSize, fontWeight: FontWeight.bold)), + Text( + user.nameOrNpub, + style: TextStyle( + fontSize: fontSize, fontWeight: FontWeight.bold), + ), ], ), ), @@ -93,7 +95,10 @@ class UsersRichText extends StatelessWidget { }); return Text.rich( - TextSpan(style: TextStyle(fontSize: fontSize), children: spans), + TextSpan( + style: TextStyle(fontSize: fontSize, height: 1.7), + children: spans, + ), ); } } diff --git a/lib/widgets/version_pill_widget.dart b/lib/widgets/version_pill_widget.dart index c3e872f..9349342 100644 --- a/lib/widgets/version_pill_widget.dart +++ b/lib/widgets/version_pill_widget.dart @@ -20,7 +20,7 @@ class VersionPillWidget extends StatelessWidget { children: [ if (app.canUpdate) PillWidget( - text: WidgetSpan( + WidgetSpan( alignment: PlaceholderAlignment.middle, child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, @@ -41,7 +41,7 @@ class VersionPillWidget extends StatelessWidget { if (app.canUpdate) Icon(Icons.arrow_right), if (app.latestMetadata != null) PillWidget( - text: WidgetSpan( + WidgetSpan( alignment: PlaceholderAlignment.middle, child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart index e19e0c0..4f6b703 100644 --- a/lib/widgets/zap_receipts.dart +++ b/lib/widgets/zap_receipts.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter_data/flutter_data.dart'; +import 'package:gap/gap.dart'; import 'package:ndk/domain_layer/entities/filter.dart'; import 'package:ndk/domain_layer/entities/nip_01_event.dart'; import 'package:zapstore/main.data.dart'; @@ -12,8 +13,10 @@ import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/zap_receipt.dart'; +import 'package:zapstore/screens/settings_screen.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/users_rich_text.dart'; +import 'package:zapstore/widgets/zap_button.dart'; class ZapReceipts extends HookConsumerWidget { ZapReceipts({ @@ -31,31 +34,50 @@ class ZapReceipts extends HookConsumerWidget { ref.users.watchAll(); final fileMetadata = app.latestMetadata; - if (fileMetadata == null) { + // NOTE: Only show zaps for self-signed apps (at least for now) + final isSelfSigned = app.signer.value == app.developer.value; + if (fileMetadata == null || !isSelfSigned) { return Container(); } final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata)); if (zapReceipts.isLoading) { - return CircularProgressIndicator(); + return _wrap( + Row( + children: [ + Gap(4), + Text('Loading zaps...'), + Gap(10), + SmallCircularProgressIndicator(), + ], + ), + ); } if (zapReceipts.value!.isEmpty) { - // Do not show anything if there are no zaps - return Container(); + return _wrap(Container()); } final receipts = zapReceipts.value!; final satsAmount = receipts.fold(0, (acc, e) => acc + e.amount); final formattedSatsAmount = kNumberFormatter.format(satsAmount); - final zapperIds = receipts.map((r) => r.senderPubkey).toSet(); - final zappers = ref.users.findManyLocalByIds(zapperIds); + const kMaxUsersToDisplay = 6; - return Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10), - child: UsersRichText( + final zapperIds = receipts.map((r) => r.senderPubkey).toSet(); + final zappers = ref.users.findManyLocalByIds(zapperIds).sortedBy((u) { + // Calculates total amount per user and uses `wrapped` + // that compares by descending order + final totalUserAmount = receipts + .where((r) => u.pubkey == r.senderPubkey) + .fold(0, (acc, e) => acc + e.amount); + print('$totalUserAmount - ${u.nameOrNpub}'); + return totalUserAmount.wrapped; + }); + + return _wrap( + UsersRichText( leadingTextSpan: TextSpan( style: TextStyle(fontSize: 16), children: [ @@ -68,12 +90,29 @@ class ZapReceipts extends HookConsumerWidget { ), users: zappers, signedInUser: signedInUser, - maxUsersToDisplay: 6, + maxUsersToDisplay: kMaxUsersToDisplay, + ), + ); + } + + Widget _wrap(Widget inner) { + return Padding( + padding: const EdgeInsets.only(top: 10, bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + SizedBox(width: double.maxFinite, child: ZapButton(app: app)), + inner, + ], ), ); } } +final _zapsPreviouslyLoadedProvider = + StateProvider.family((_, id) => false); + class ZapReceiptsNotifier extends StateNotifier>> { Ref ref; final FileMetadata fileMetadata; @@ -92,20 +131,31 @@ class ZapReceiptsNotifier extends StateNotifier>> { return; } - final localZapReceipts = ref.zapReceipts.zapReceiptAdapter - .findByRecipient( - pubkey: developerPubkey, eventId: fileMetadata.id!.toString()) - .toSet(); - _loadZappers(localZapReceipts); - state = AsyncData(localZapReceipts); + final wereZapsPreviouslyLoaded = + ref.read(_zapsPreviouslyLoadedProvider(fileMetadata.event.id)); - // NOTE: ideally this caching stuff should be handled by purplebase - final latestReceiptTimestamp = localZapReceipts - .sortedBy((z) => z.event.createdAt) - .lastOrNull - ?.event - .createdAt - .millisecondsSinceEpoch; + int? latestReceiptTimestamp; + + final localZapReceipts = ref.zapReceipts.zapReceiptAdapter + .findByEventId(fileMetadata.id!.toString()) + .toSet(); + + // If there were no zaps locally and this is the first time loading, keep the loading state + // (yes, purplebase should handle this) + if (localZapReceipts.isEmpty && !wereZapsPreviouslyLoaded) { + state = AsyncLoading(); + } else { + _loadZappers(localZapReceipts); + state = AsyncData(localZapReceipts); + + // NOTE: ideally this caching stuff should be handled by purplebase + latestReceiptTimestamp = localZapReceipts + .sortedBy((z) => z.event.createdAt) + .lastOrNull + ?.event + .createdAt + .millisecondsSinceEpoch; + } final receiptsResponse = adapter.socialRelays.ndk!.requests.subscription( filters: [ @@ -125,8 +175,22 @@ class ZapReceiptsNotifier extends StateNotifier>> { final zapReceipts = data.map((r) => ZapReceipt.fromJson(r.toJson()).init().saveLocal()); await _loadZappers(zapReceipts); + + // Mark loading done for this event + ref + .read(_zapsPreviouslyLoadedProvider(fileMetadata.event.id).notifier) + .state = true; + state = AsyncData({if (state.hasValue) ...state.value!, ...zapReceipts}); }); + + // If after 10 seconds we receive no zaps, then change the state to empty + // (to prevent forever spinners) + Timer(Duration(seconds: 10), () { + if (mounted && state is AsyncLoading) { + state = AsyncData({}); + } + }); } // Load senders - this tedious work will be handled by purplebase at some point @@ -160,3 +224,26 @@ class ZapReceiptsNotifier extends StateNotifier>> { final zapReceiptsNotifier = StateNotifierProvider.autoDispose .family>, FileMetadata>( ZapReceiptsNotifier.new); + +// Ridiculous to have to do this in Dart + +extension _IntWrapperExt on int { + _IntDescComparableWrapper get wrapped => _IntDescComparableWrapper(this); +} + +class _IntDescComparableWrapper + implements Comparable<_IntDescComparableWrapper> { + final int value; + + _IntDescComparableWrapper(this.value); + + @override + int compareTo(_IntDescComparableWrapper other) { + return other.value.compareTo(value); + } + + @override + String toString() { + return value.toString(); + } +}