Working zaps WIP

This commit is contained in:
franzap
2025-01-02 15:00:15 -03:00
parent 076338b51e
commit 14a54936fc
17 changed files with 313 additions and 335 deletions
+9 -4
View File
@@ -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 = <String, Provider<Adapter<DataModelMixin>>>{
'appCurationSets': appCurationSetsAdapterProvider,
@@ -18,7 +20,8 @@ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
'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<Settings> get settings =>
watch(settingsAdapterProvider)..internalWatch = watch;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
Adapter<ZapReceipt> get zapReceipts =>
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
}
extension AdapterRefX on Ref {
@@ -49,6 +54,6 @@ extension AdapterRefX on Ref {
Adapter<Settings> get settings =>
watch(settingsAdapterProvider)..internalWatch = watch;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
Adapter<ZapReceipt> get zapReceipts =>
watch(zapReceiptsAdapterProvider)..internalWatch = watch;
}
+8 -3
View File
@@ -353,10 +353,15 @@ mixin AppAdapter on Adapter<App> {
for (final Map<String, dynamic> 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);
+1 -1
View File
@@ -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<T extends DataModelMixin<T>> on Adapter<T> {
RelayMessageNotifier get relay =>
+16 -12
View File
@@ -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<User> {
@override
Object? get id => event.id;
Future<void> zap(int amount,
Future<void> 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<User> {
},
);
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<User> {
}
// 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<Map<String, dynamic>> fetchLnUrl() async {
+35
View File
@@ -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<ZapReceipt> {
final BelongsTo<User> recipient;
final BelongsTo<User> signer;
@override
Object? get id => event.id;
ZapReceipt.fromJson(super.map)
: recipient = belongsTo(map['recipient']),
signer = belongsTo(map['signer']),
super.fromJson();
Map<String, dynamic> toJson() => super.toMap();
}
mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
@override
DeserializedData<ZapReceipt> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>;
map['recipient'] = base.BaseUtil.getTagSet(map['tags'], 'p');
}
return super.deserialize(data);
}
}
+71
View File
@@ -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<ZapReceipt> {
static final Map<String, RelationshipMeta> _kZapReceiptRelationshipMetas = {
'recipient': RelationshipMeta<User>(
name: 'recipient',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as ZapReceipt).recipient,
),
'signer': RelationshipMeta<User>(
name: 'signer',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as ZapReceipt).signer,
)
};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kZapReceiptRelationshipMetas;
@override
ZapReceipt deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => ZapReceipt.fromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = model.toJson();
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _zapReceiptsFinders = <String, dynamic>{};
class $ZapReceiptAdapter = Adapter<ZapReceipt>
with _$ZapReceiptAdapter, NostrAdapter<ZapReceipt>;
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
}
extension ZapReceiptRelationshipGraphNodeX
on RelationshipGraphNode<ZapReceipt> {
RelationshipGraphNode<User> get recipient {
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['recipient']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<User> get signer {
final meta = _$ZapReceiptAdapter._kZapReceiptRelationshipMetas['signer']
as RelationshipMeta<User>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
+3 -2
View File
@@ -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),
+1 -1
View File
@@ -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/'),
-59
View File
@@ -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<void> 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<String?> decrypt(String msg, String destPubKey, {String? id}) async {
final npub = publicKey.startsWith('npub')
? publicKey
: Nip19.encodePubKey(publicKey);
Map<dynamic, dynamic> map =
await _signerPlugin.nip04Decrypt(msg, id!, npub, destPubKey);
return map['signature'];
}
@override
Future<String?> encrypt(String msg, String destPubKey, {String? id}) async {
final npub = publicKey.startsWith('npub')
? publicKey
: Nip19.encodePubKey(publicKey);
Map<dynamic, dynamic> map =
await _signerPlugin.nip04Encrypt(msg, id!, npub, destPubKey);
return map['signature'];
}
@override
bool canSign() {
return publicKey.isNotEmpty;
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ class AmberSigner extends Signer {
@override
Future<E> sign<E extends Event<E>>(PartialEvent<E> 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<E>()!.call(signedEvent);
}
-110
View File
@@ -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<AsyncValue<List<ZapReceipt>>?> {
Ref ref;
ZapReceiptsNotifier(this.ref) : super(null);
StreamSubscription<ZapReceipt>? sub;
Future<void> 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<AsyncValue<ZapResponse?>> {
Ref ref;
ZapNotifier(this.ref) : super(AsyncData(null));
Future<void> 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, AsyncValue<ZapResponse?>>(
ZapNotifier.new);
final zapReceiptsNotifier =
StateNotifierProvider<ZapReceiptsNotifier, AsyncValue<List<ZapReceipt>>?>(
ZapReceiptsNotifier.new);
+1
View File
@@ -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,
+25 -26
View File
@@ -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();
}
+110
View File
@@ -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<AsyncValue<Set<ZapReceipt>>> {
Ref ref;
FileMetadata fileMetadata;
StreamSubscription<Nip01Event>? 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<ZapReceipt>()!.call(data.toJson())
});
});
}
@override
void dispose() {
sub?.cancel();
super.dispose();
}
}
final zapReceiptsNotifier = StateNotifierProvider.autoDispose
.family<ZapReceiptsNotifier, AsyncValue<Set<ZapReceipt>>, FileMetadata>(
ZapReceiptsNotifier.new);
-102
View File
@@ -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<List<User>>(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<List<User>, ({Iterable<String> zapperIds})>((ref, arg) {
// TODO it should load from either local or remote if not cached locally still
return ref.users.findManyLocalByIds(arg.zapperIds);
});
+29 -6
View File
@@ -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:
+1 -6
View File
@@ -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: