mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Fix zaps caching, loading, display and order by amount
This commit is contained in:
+30
-27
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user