mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Working zaps WIP
This commit is contained in:
+9
-4
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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/'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user