diff --git a/lib/main.data.dart b/lib/main.data.dart index 36d2d28..cfb5041 100644 --- a/lib/main.data.dart +++ b/lib/main.data.dart @@ -3,13 +3,15 @@ import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:zapstore/models/app.dart'; + import 'package:zapstore/models/app_curation_set.dart'; +import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/local_app.dart'; import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/user.dart'; +import 'package:zapstore/models/zap_receipt.dart'; final adapterProvidersMap = >>{ 'appCurationSets': appCurationSetsAdapterProvider, @@ -18,7 +20,8 @@ final adapterProvidersMap = >>{ 'localApps': localAppsAdapterProvider, 'releases': releasesAdapterProvider, 'settings': settingsAdapterProvider, - 'users': usersAdapterProvider + 'users': usersAdapterProvider, + 'zapReceipts': zapReceiptsAdapterProvider }; extension AdapterWidgetRefX on WidgetRef { @@ -34,6 +37,8 @@ extension AdapterWidgetRefX on WidgetRef { Adapter get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; + Adapter get zapReceipts => + watch(zapReceiptsAdapterProvider)..internalWatch = watch; } extension AdapterRefX on Ref { @@ -49,6 +54,6 @@ extension AdapterRefX on Ref { Adapter get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter get users => watch(usersAdapterProvider)..internalWatch = watch; - + Adapter get zapReceipts => + watch(zapReceiptsAdapterProvider)..internalWatch = watch; } - diff --git a/lib/models/app.dart b/lib/models/app.dart index 502d8a7..a20c90c 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -353,10 +353,15 @@ mixin AppAdapter on Adapter { for (final Map map in list) { final tags = map['tags']; - final pubkey = base.BaseUtil.getTag(tags, 'pubkey'); - map['developer'] = base.BaseUtil.getTag(tags, 'zap') ?? - (pubkey != kZapstorePubkey ? pubkey : null); + final pubkey = map['pubkey']; map['localApp'] = base.BaseUtil.getTag(tags, 'd')!; + // Find zap recipient as specified in event or fall back to author's pubkey + map['developer'] = base.BaseUtil.getTag(tags, 'zap') ?? pubkey; + // If app is signed by Zapstore (except the Zapstore app), remove from being the developer + if (pubkey == kZapstorePubkey && + map['localApp'] != kZapstoreAppIdentifier) { + map.remove('developer'); + } } return super.deserialize(data); diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index 4f8cb9e..8057da3 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -13,7 +13,7 @@ import 'package:zapstore/widgets/latest_releases_container.dart'; // NOTE: Very important to use const in relay args to preserve equality in Riverpod families // const kAppRelays = {'ws://10.0.2.2:3000'}; const kAppRelays = {'wss://relay.zapstore.dev'}; -const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net'}; +const kSocialRelays = {'wss://relay.damus.io', 'wss://relay.primal.net', 'ndk'}; mixin NostrAdapter> on Adapter { RelayMessageNotifier get relay => diff --git a/lib/models/user.dart b/lib/models/user.dart index 541709e..15dc2f4 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:zapstore/main.data.dart'; import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/settings.dart'; +import 'package:zapstore/navigation/app_initializer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nwc.dart'; @@ -17,35 +18,39 @@ class User extends base.User with DataModelMixin { @override Object? get id => event.id; - Future zap(int amount, + Future zap(int amountInSats, {required base.Event event, String? comment}) async { final adapter = DataModel.adapterFor(this) as NostrAdapter; final authorPubkey = event.event.pubkey; + print( + 'bro wants to zap $amountInSats to $authorPubkey for ${event.event.id}'); final author = adapter.ref.users.findOneLocalById(authorPubkey); if (author?.lud16 == null) { throw 'cant zap'; } + final amountInMillisats = amountInSats * 1000; final partialZapRequest = base.PartialZapRequest(); partialZapRequest ..addLinkedEvent(event) - ..addLinkedUser(this) - ..amount = amount; - final zapRequest = await partialZapRequest - .signWith(base.Bip340PrivateKeySigner(kZapstorePubkey)); - print(zapRequest); + ..addLinkedUser(author!) + ..relays = kSocialRelays.where((r) => r != 'ndk') + ..amount = amountInMillisats + ..comment = comment; + + final zapRequest = + await partialZapRequest.signWith(amberSigner, withPubkey: pubkey); // Now we fetch the invoice - final lnurlResponse = await author!.fetchLnUrl(); + final lnurlResponse = await author.fetchLnUrl(); final recipientAllowsNostr = lnurlResponse['allowsNostr']; final recipientPubkey = lnurlResponse['nostrPubkey']; // All amounts are in millisats final recipientMin = lnurlResponse['minSendable']; final recipientMax = lnurlResponse['maxSendable']; - final amountInMillisats = amount * 1000; if (recipientAllowsNostr != true && recipientPubkey != null) { throw 'cant zap, recipient does not allow nostr'; @@ -69,12 +74,9 @@ class User extends base.User with DataModelMixin { }, ); - print(callbackUri); - final response = await http.get(callbackUri); final invoiceMap = jsonDecode(response.body); - print(invoiceMap); final invoice = invoiceMap['pr']; if (invoice == null) { throw 'cant zap, no invoice'; @@ -86,7 +88,9 @@ class User extends base.User with DataModelMixin { } // Pay the invoice via NWC - adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, invoice: invoice); + final rw = await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection, + invoice: invoice, timeout: Duration(seconds: 20)); + print(rw); } Future> fetchLnUrl() async { diff --git a/lib/models/zap_receipt.dart b/lib/models/zap_receipt.dart new file mode 100644 index 0000000..1c6f66d --- /dev/null +++ b/lib/models/zap_receipt.dart @@ -0,0 +1,35 @@ +import 'package:flutter_data/flutter_data.dart'; +import 'package:purplebase/purplebase.dart' as base; +import 'package:zapstore/models/nostr_adapter.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/utils/extensions.dart'; + +part 'zap_receipt.g.dart'; + +@DataAdapter([NostrAdapter]) +class ZapReceipt extends base.ZapReceipt with DataModelMixin { + final BelongsTo recipient; + final BelongsTo signer; + + @override + Object? get id => event.id; + + ZapReceipt.fromJson(super.map) + : recipient = belongsTo(map['recipient']), + signer = belongsTo(map['signer']), + super.fromJson(); + + Map toJson() => super.toMap(); +} + +mixin ZapReceiptAdapter on Adapter { + @override + DeserializedData deserialize(Object? data, {String? key}) { + final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + final map = e as Map; + map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p'); + } + return super.deserialize(data); + } +} diff --git a/lib/models/zap_receipt.g.dart b/lib/models/zap_receipt.g.dart new file mode 100644 index 0000000..6f100fa --- /dev/null +++ b/lib/models/zap_receipt.g.dart @@ -0,0 +1,71 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'zap_receipt.dart'; + +// ************************************************************************** +// AdapterGenerator +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, duplicate_ignore + +mixin _$ZapReceiptAdapter on Adapter { + static final Map _kZapReceiptRelationshipMetas = { + 'recipient': RelationshipMeta( + name: 'recipient', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as ZapReceipt).recipient, + ), + 'signer': RelationshipMeta( + name: 'signer', + type: 'users', + kind: 'BelongsTo', + instance: (_) => (_ as ZapReceipt).signer, + ) + }; + + @override + Map get relationshipMetas => + _kZapReceiptRelationshipMetas; + + @override + ZapReceipt deserializeLocal(map, {String? key}) { + map = transformDeserialize(map); + return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key); + } + + @override + Map serializeLocal(model, {bool withRelationships = true}) { + final map = model.toJson(); + return transformSerialize(map, withRelationships: withRelationships); + } +} + +final _zapReceiptsFinders = {}; + +class $ZapReceiptAdapter = Adapter + with _$ZapReceiptAdapter, NostrAdapter; + +final zapReceiptsAdapterProvider = Provider>( + (ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders))); + +extension ZapReceiptAdapterX on Adapter { + NostrAdapter get nostrAdapter => this as NostrAdapter; +} + +extension ZapReceiptRelationshipGraphNodeX + on RelationshipGraphNode { + RelationshipGraphNode get recipient { + final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient'] + as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } + + RelationshipGraphNode get signer { + final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer'] + as RelationshipMeta; + return meta.clone( + parent: this is RelationshipMeta ? this as RelationshipMeta : null); + } +} diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index cfe846c..bc4578d 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -19,7 +19,7 @@ import 'package:zapstore/widgets/spinning_logo.dart'; import 'package:zapstore/widgets/versioned_app_header.dart'; import 'package:zapstore/widgets/wot_container.dart'; import 'package:zapstore/widgets/zap_button.dart'; -import 'package:zapstore/widgets/zaps.dart'; +import 'package:zapstore/widgets/zap_receipts.dart'; class AppDetailScreen extends HookConsumerWidget { final App model; @@ -157,7 +157,8 @@ class AppDetailScreen extends HookConsumerWidget { child: SignerAndDeveloperRow(app: app), ), Gap(20), - Zaps(app: app), + if (app.latestMetadata != null) + ZapReceipts(fileMetadata: app.latestMetadata!), Gap(20), ZapButton(app: app), Gap(20), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5de3f60..9577e54 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -81,7 +81,7 @@ class SettingsScreen extends HookConsumerWidget { PartialDirectMessage( content: feedbackController.text.trim(), receiver: kZapstorePubkey.npub), - asUser: user.pubkey); + withPubkey: user.pubkey); try { final response = await http.post( Uri.parse('https://relay.zapstore.dev/'), diff --git a/lib/utils/nip55_event_signer.dart b/lib/utils/nip55_event_signer.dart deleted file mode 100644 index 140e586..0000000 --- a/lib/utils/nip55_event_signer.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:convert'; - -import 'package:ndk/ndk.dart'; -import 'package:ndk/shared/nips/nip19/nip19.dart'; -import 'package:signer_plugin/signer_plugin.dart'; - -class Nip55EventSigner implements EventSigner { - final _signerPlugin = SignerPlugin(); - bool isAvailable = false; - - final String publicKey; - - /// get a amber event signer - Nip55EventSigner({ - required this.publicKey, - }); - - @override - Future sign(Nip01Event event) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - final signedMessage = - await _signerPlugin.signEvent(jsonEncode(event.toJson()), "", npub); - final signedEvent = jsonDecode(signedMessage['event']); - - event.sig = signedEvent['sig']; - } - - @override - String getPublicKey() { - return publicKey; - } - - @override - Future decrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - Future encrypt(String msg, String destPubKey, {String? id}) async { - final npub = publicKey.startsWith('npub') - ? publicKey - : Nip19.encodePubKey(publicKey); - Map map = - await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey); - return map['signature']; - } - - @override - bool canSign() { - return publicKey.isNotEmpty; - } -} diff --git a/lib/utils/signers.dart b/lib/utils/signers.dart index dd1afb6..e7e6cc1 100644 --- a/lib/utils/signers.dart +++ b/lib/utils/signers.dart @@ -27,7 +27,7 @@ class AmberSigner extends Signer { @override Future sign>(PartialEvent partialEvent, - {String? asUser}) async { + {String? withPubkey}) async { if (!isAvailable) { throw Exception("Cannot sign, missing Amber"); } @@ -36,7 +36,7 @@ class AmberSigner extends Signer { final signedMessage = await _signerPlugin.nip04Encrypt( partialEvent.event.content, "", - asUser!.npub, + withPubkey!.npub, (partialEvent as DirectMessage).receiver.hexKey); final encryptedContent = signedMessage['result']; partialEvent.event.content = encryptedContent; @@ -48,7 +48,7 @@ class AmberSigner extends Signer { if (e.value != null) e.key: e.value }; final signedMessage = - await _signerPlugin.signEvent(jsonEncode(map), "", asUser!); + await _signerPlugin.signEvent(jsonEncode(map), "", withPubkey!); final signedEvent = jsonDecode(signedMessage['event']); return Event.getConstructor()!.call(signedEvent); } diff --git a/lib/utils/zap.dart b/lib/utils/zap.dart deleted file mode 100644 index 0745f8c..0000000 --- a/lib/utils/zap.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_data/flutter_data.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zap_receipt.dart'; -import 'package:ndk/domain_layer/usecases/zaps/zaps.dart'; -import 'package:purplebase/purplebase.dart' hide FileMetadata, User; -import 'package:zapstore/models/file_metadata.dart'; -import 'package:zapstore/models/nostr_adapter.dart'; -import 'package:zapstore/utils/nip55_event_signer.dart'; -import 'package:zapstore/utils/nwc.dart'; - -import '../models/user.dart'; - -class ZapReceiptsNotifier extends StateNotifier>?> { - Ref ref; - - ZapReceiptsNotifier(this.ref) : super(null); - - StreamSubscription? sub; - - Future fetchZaps(FileMetadata fileMetadata) async { - state = null; - final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); - if (socialRelays.ndk != null) { - state = AsyncValue.loading(); - - final receiptsResponse = socialRelays.ndk!.zaps.fetchZappedReceipts( - pubKey: fileMetadata.event.pubkey, - eventId: fileMetadata.id!.toString()); - sub = receiptsResponse.listen((receipt) { - addZapReceipt(receipt); - }); - } - } - - void addZapReceipt(ZapReceipt receipt) { - if (state == null || state!.value == null) { - state = AsyncValue.data([receipt]); - } else { - final receipts = state!.value!; - receipts.add(receipt); - state = AsyncValue.data(receipts); - } - } - - @override - void dispose() { - sub?.cancel(); - super.dispose(); - } -} - -class ZapNotifier extends StateNotifier> { - Ref ref; - - ZapNotifier(this.ref) : super(AsyncData(null)); - - Future zap( - {required User user, - String? eventId, - required String lnurl, - required int amount, - required String pubKey}) async { - final nwcConnectionState = ref.read(nwcConnectionProvider.notifier).state; - if (nwcConnectionState.value == null) { - return; - } - state = AsyncValue.loading(); - final signer = Nip55EventSigner(publicKey: user.pubkey); - final socialRelays = ref.read(relayProviderFamily(kSocialRelays).notifier); - final relays = socialRelays.ndk != null - ? socialRelays.ndk!.config.bootstrapRelays - : kSocialRelays.where((r) => r != 'ndk'); - try { - final zapResponse = await ndkForNwc.zaps.zap( - nwcConnection: nwcConnectionState.value!, - lnurl: lnurl, - amountSats: amount, - fetchZapReceipt: true, - signer: signer, - relays: relays, - pubKey: pubKey, - eventId: eventId, - ); - if (zapResponse.payInvoiceResponse != null && - zapResponse.payInvoiceResponse!.preimage.isNotEmpty) { - state = AsyncValue.data(zapResponse); - final zapReceipt = await zapResponse.zapReceipt; - if (zapReceipt != null) { - ref.read(zapReceiptsNotifier.notifier).addZapReceipt(zapReceipt); - } - } else { - state = AsyncValue.error( - zapResponse.payInvoiceResponse?.errorMessage ?? - "couldn't pay, unknown error", - StackTrace.current); - } - } catch (e, stack) { - state = AsyncValue.error(e, stack); - } - } -} - -final zapProvider = - StateNotifierProvider>( - ZapNotifier.new); - -final zapReceiptsNotifier = - StateNotifierProvider>?>( - ZapReceiptsNotifier.new); diff --git a/lib/widgets/sign_in_container.dart b/lib/widgets/sign_in_container.dart index c68a6bf..554254b 100644 --- a/lib/widgets/sign_in_container.dart +++ b/lib/widgets/sign_in_container.dart @@ -141,6 +141,7 @@ class SignInContainer extends HookConsumerWidget { // local user to represent this new npub user ??= User.fromJson({ 'id': signedInNpub.hexKey, + 'kind': 0, 'pubkey': signedInNpub.hexKey, 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, diff --git a/lib/widgets/zap_button.dart b/lib/widgets/zap_button.dart index 9eba80f..8217f0c 100644 --- a/lib/widgets/zap_button.dart +++ b/lib/widgets/zap_button.dart @@ -1,6 +1,5 @@ import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.data.dart'; @@ -8,7 +7,6 @@ import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/settings.dart'; import 'package:zapstore/navigation/router.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/zap.dart'; import '../utils/nwc.dart'; @@ -23,7 +21,7 @@ class ZapButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nwcConnection = ref.watch(nwcConnectionProvider); - final zapStatus = ref.watch(zapProvider); + // final zapStatus = ref.watch(zapProvider); final user = ref.settings .watchOne('_', alsoWatch: (_) => {_.user}) .model! @@ -33,29 +31,29 @@ class ZapButton extends HookConsumerWidget { final amountController = TextEditingController(); // We use value as default text, for progress only interested in loading/error substates - final zapButtonText = switch (zapStatus) { - AsyncLoading() => 'Zapping... ⚡⚡⚡', - AsyncError() => 'Error zapping!', - AsyncValue() => 'Zap the dev ⚡', - }; + // final zapButtonText = switch (zapStatus) { + // AsyncLoading() => 'Zapping... ⚡⚡⚡', + // AsyncError() => 'Error zapping!', + // AsyncValue() => 'Zap the dev ⚡', + // }; // Show toast if could not zap - useMemoized(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (zapStatus.value != null) { - // final receipt = await zapStatus.value!.zapReceipt; - // TODO: zapStatus should include the amount of sats without needing async - // I could read zapReceiptsNotifier's state, but how I know it's this one? - context.showInfo('Zapped!', - description: 'You just sent the dev some sats'); - } - if (zapStatus.hasError) { - context.showError( - title: 'Could not zap', - description: 'Error sending zap: ${zapStatus.error}'); - } - }); - }, [zapStatus.value, zapStatus.error]); + // useMemoized(() { + // WidgetsBinding.instance.addPostFrameCallback((_) { + // if (zapStatus.value != null) { + // // final receipt = await zapStatus.value!.zapReceipt; + // // TODO: zapStatus should include the amount of sats without needing async + // // I could read zapReceiptsNotifier's state, but how I know it's this one? + // context.showInfo('Zapped!', + // description: 'You just sent the dev some sats'); + // } + // if (zapStatus.hasError) { + // context.showError( + // title: 'Could not zap', + // description: 'Error sending zap: ${zapStatus.error}'); + // } + // }); + // }, [zapStatus.value, zapStatus.error]); return app.developer.value != null ? AsyncButtonBuilder( @@ -149,7 +147,8 @@ class ZapButton extends HookConsumerWidget { if (amount > 0) { // User should be able to sign any event // Proposed API: - await user.zap(amount, event: app.latestMetadata!); + await user.zap(amount, + event: app.latestMetadata!, comment: 'From zapstore!'); // final lnurl = developer.lud16!; // await ref.read(zapProvider.notifier).zap( @@ -163,7 +162,7 @@ class ZapButton extends HookConsumerWidget { builder: (context, child, callback, state) { return ElevatedButton(onPressed: callback, child: child); }, - child: Text(zapButtonText), + child: Text('Zap!'), ) : Container(); } diff --git a/lib/widgets/zap_receipts.dart b/lib/widgets/zap_receipts.dart new file mode 100644 index 0000000..0e7f487 --- /dev/null +++ b/lib/widgets/zap_receipts.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:ndk/domain_layer/entities/nip_01_event.dart'; +import 'package:purplebase/purplebase.dart' hide FileMetadata; +import 'package:zapstore/main.data.dart'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/models/file_metadata.dart'; +import 'package:zapstore/models/user.dart'; +import 'package:zapstore/widgets/rounded_image.dart'; + +class ZapReceipts extends HookConsumerWidget { + ZapReceipts({ + super.key, + required this.fileMetadata, + }); + + final FileMetadata fileMetadata; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final zapReceipts = ref.watch(zapReceiptsNotifier(fileMetadata)); + + if (zapReceipts.isLoading) { + return CircularProgressIndicator(); + } + + if (zapReceipts.value!.isEmpty) { + // Do not show anything if there are no zaps + return Container(); + } + + final receipts = zapReceipts.value!; + final totalAmountInSats = receipts.fold(0, (acc, e) => acc + e.amount); + + final senderIds = receipts.map((r) => r.senderPubkey).toSet(); + final senders = ref.users.findManyLocalByIds(senderIds); + + return Row( + children: [ + Text("⚡ $totalAmountInSats sats (${receipts.length} zaps)"), + Gap(5), + RichText( + text: TextSpan( + children: [ + for (final user in senders) + TextSpan( + style: TextStyle(height: 1.6), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + RoundedImage(url: user.avatarUrl, size: 20), + ], + ), + ), + ], + ) + ], + ), + ) + ], + ); + } +} + +class ZapReceiptsNotifier extends StateNotifier>> { + Ref ref; + FileMetadata fileMetadata; + StreamSubscription? sub; + + ZapReceiptsNotifier(this.ref, this.fileMetadata) + : super(AsyncData(ref.zapReceipts + // TODO: Perform an actual query + .findAllLocal() + .where((r) => + r.recipient.id == fileMetadata.author.id && + r.eventId == fileMetadata.id) + .toSet())) { + final adapter = ref.users.nostrAdapter; + + final recipient = + fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey; + print('querying for $recipient and id ${fileMetadata.event.id}'); + final receiptsResponse = adapter.socialRelays.ndk!.zaps + .subscribeToZapReceipts( + pubKey: recipient, eventId: fileMetadata.event.id); + + sub = receiptsResponse.stream.listen((data) { + state = AsyncData({ + if (state.hasValue) ...state.value!, + Event.getConstructor()!.call(data.toJson()) + }); + }); + } + + @override + void dispose() { + sub?.cancel(); + super.dispose(); + } +} + +final zapReceiptsNotifier = StateNotifierProvider.autoDispose + .family>, FileMetadata>( + ZapReceiptsNotifier.new); diff --git a/lib/widgets/zaps.dart b/lib/widgets/zaps.dart deleted file mode 100644 index 5f6d12d..0000000 --- a/lib/widgets/zaps.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:zapstore/main.data.dart'; -import 'package:zapstore/models/app.dart'; -import 'package:zapstore/models/user.dart'; -import 'package:zapstore/utils/zap.dart'; -import 'package:zapstore/widgets/rounded_image.dart'; - -class Zaps extends HookConsumerWidget { - Zaps({ - super.key, - required this.app, - }); - - final App app; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final zapReceipts = ref.watch(zapReceiptsNotifier); - - if (app.developer.value?.lud16 != null && app.latestMetadata != null) { - if (zapReceipts == null) { - Future(() { - ref.read(zapReceiptsNotifier.notifier).fetchZaps(app.latestMetadata!); - }); - } else { - final receipts = zapReceipts.value; - if (receipts != null) { - receipts - .sort((a, b) => (b.amountSats ?? 0).compareTo(a.amountSats ?? 0)); - - var eventSum = 0; - for (var receipt in receipts) { - eventSum += receipt.amountSats ?? 0; - } - final senderIds = - receipts.map((receipt) => receipt.sender).nonNulls.toSet(); - - final senders = - ref.watch(zappersProvider((zapperIds: senderIds.take(10)))); - - final zapperAvatars = switch (senders) { - AsyncData>(value: final users) => Builder( - builder: (context) { - return RichText( - text: TextSpan(children: [ - for (final user in users) - TextSpan( - style: TextStyle(height: 1.6), - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - RoundedImage(url: user.avatarUrl, size: 20), - ], - ), - ), - ], - ) - ])); - }, - ), - AsyncError(:final error) => - Center(child: Text('Error loading zapper profiles: $error')), - // Loading state - _ => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(), - ), - ], - ), - ), - ) - }; - return Row( - children: [ - Text("⚡ $eventSum sats (${receipts.length} zaps)"), - Gap(5), - zapperAvatars - ], - ); - } - } - } - return Container(); - } -} - -final zappersProvider = FutureProvider.autoDispose - .family, ({Iterable zapperIds})>((ref, arg) { - // TODO it should load from either local or remote if not cached locally still - return ref.users.findManyLocalByIds(arg.zapperIds); -}); diff --git a/pubspec.lock b/pubspec.lock index 0b4007a..e727d67 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -286,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + url: "https://pub.dev" + source: hosted + version: "2.7.0" cupertino_icons: dependency: "direct main" description: @@ -334,6 +342,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + elliptic: + dependency: transitive + description: + name: elliptic + sha256: "0c303d810603953a65dc39c4c542fb7538defd9e212403c54c266140819523b6" + url: "https://pub.dev" + source: hosted + version: "0.3.11" equatable: dependency: transitive description: @@ -718,6 +734,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + logger: + dependency: transitive + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: @@ -785,12 +809,11 @@ packages: ndk: dependency: "direct main" description: - path: "packages/ndk" - ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 - resolved-ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 - url: "https://github.com/relaystr/ndk" - source: git - version: "0.2.0-dev005" + name: ndk + sha256: e17cb3aad2936c62978891e0d3db4dc37f3ded7efbdf89e571c3927158da9135 + url: "https://pub.dev" + source: hosted + version: "0.2.2" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4b68a89..43bec19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,14 +53,9 @@ dependencies: mutex: ^3.1.0 easy_image_viewer: ^1.5.1 flutter_secure_storage: ^9.2.2 - ndk: ^0.2.0-dev005 + ndk: ^0.2.2 dependency_overrides: - ndk: - git: - url: https://github.com/relaystr/ndk - path: packages/ndk - ref: b864cd2f842b6774dcce3decb9fe75c6c7887172 #lnurl-zaps purplebase: path: ../purplebase # git: