Fix zaps caching, loading, display and order by amount

This commit is contained in:
franzap
2025-01-07 17:41:18 -03:00
parent 1a5366e509
commit b834816a57
11 changed files with 162 additions and 72 deletions
+30 -27
View File
@@ -18,6 +18,35 @@ class User extends base.User with DataModelMixin<User> {
@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<String, dynamic> toJson() => super.toMap();
@DataRelationship(inverse: 'followers')
final HasMany<User> following;
@DataRelationship(inverse: 'following')
final HasMany<User> followers;
@DataRelationship(inverse: 'user')
final BelongsTo<Settings> settings;
String get nameOrNpub => name ?? npub.shorten;
// Zaps
Future<void> 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<User> {
}
// 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<User> {
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<String, dynamic> toJson() => super.toMap();
@DataRelationship(inverse: 'followers')
final HasMany<User> following;
@DataRelationship(inverse: 'following')
final HasMany<User> followers;
@DataRelationship(inverse: 'user')
final BelongsTo<Settings> settings;
String get nameOrNpub => name ?? npub.shorten;
}
mixin UserAdapter on NostrAdapter<User> {
+3 -4
View File
@@ -33,11 +33,10 @@ mixin ZapReceiptAdapter on Adapter<ZapReceipt> {
return super.deserialize(data);
}
List<ZapReceipt> findByRecipient(
{required String pubkey, required String eventId}) {
List<ZapReceipt> 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);
}
}
+2
View File
@@ -72,6 +72,8 @@ final appInitializer = FutureProvider<void>((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) {
+1 -4
View File
@@ -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(
+1 -1
View File
@@ -21,7 +21,6 @@ class AmberSigner extends Signer {
@override
Future<String?> 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;
}
+1 -1
View File
@@ -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)),
),
],
);
+1 -1
View File
@@ -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,
+2 -5
View File
@@ -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) {
+9 -4
View File
@@ -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,
),
);
}
}
+2 -2
View File
@@ -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,
+110 -23
View File
@@ -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<bool, String>((_, id) => false);
class ZapReceiptsNotifier extends StateNotifier<AsyncValue<Set<ZapReceipt>>> {
Ref ref;
final FileMetadata fileMetadata;
@@ -92,20 +131,31 @@ class ZapReceiptsNotifier extends StateNotifier<AsyncValue<Set<ZapReceipt>>> {
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<AsyncValue<Set<ZapReceipt>>> {
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<AsyncValue<Set<ZapReceipt>>> {
final zapReceiptsNotifier = StateNotifierProvider.autoDispose
.family<ZapReceiptsNotifier, AsyncValue<Set<ZapReceipt>>, 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();
}
}