Merge branch 'zaps'

This commit is contained in:
franzap
2025-01-05 22:41:35 -03:00
44 changed files with 1590 additions and 449 deletions
+1
View File
@@ -32,6 +32,7 @@ linter:
avoid_print: false
prefer_const_literals_to_create_immutables: false
no_wildcard_variable_uses: false
omit_local_variable_types: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+1
View File
@@ -5,6 +5,7 @@
android:label="Zapstore"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+7 -1
View File
@@ -11,6 +11,7 @@ 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,
@@ -19,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 {
@@ -35,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 {
@@ -50,4 +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;
}
+23 -19
View File
@@ -12,7 +12,7 @@ import 'package:install_plugin/install_plugin.dart';
import 'package:mutex/mutex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/local_app.dart';
@@ -28,7 +28,10 @@ part 'app.g.dart';
final mutex = Mutex();
@DataAdapter([NostrAdapter, AppAdapter])
class App extends BaseApp with DataModelMixin<App> {
class App extends base.App with DataModelMixin<App> {
@override
Object? get id => event.id;
final HasMany<Release> releases;
final BelongsTo<User> signer;
final BelongsTo<User> developer;
@@ -149,13 +152,7 @@ class App extends BaseApp with DataModelMixin<App> {
await file.delete();
}
var url = latestMetadata!.urls.first;
// TODO: Remove in 0.2.x
if (url.startsWith('https://cdn.zap.store')) {
url = url.replaceFirst(
'https://cdn.zap.store', 'https://cdn.zapstore.dev');
}
final url = latestMetadata!.urls.first;
final (baseDirectory, directory, filename) =
await Task.split(filePath: file.path);
@@ -266,14 +263,14 @@ mixin AppAdapter on Adapter<App> {
// Find most recent `created_at` from returned apps
// and assign it to their queried at value for caching
final m = apps.isNotEmpty
? apps.fold(apps.first.createdAt!, (acc, e) {
return acc.isAfter(e.createdAt!) ? acc : e.createdAt!;
? apps.fold(apps.first.event.createdAt, (acc, e) {
return acc.isAfter(e.event.createdAt) ? acc : e.event.createdAt;
})
: null;
if (m != null) {
for (final app in apps) {
queriedAtMap[app.identifier!] = m;
queriedAtMap[app.identifier] = m;
}
}
} else {
@@ -283,8 +280,9 @@ mixin AppAdapter on Adapter<App> {
// Find all appid@version ($3) as we need to pick one tag to query on
// (filters by kind ($1) and pubkey ($2) done locally)
final latestReleaseIdentifiers =
apps.map((app) => app.linkedReplaceableEvents.firstOrNull?.$3).nonNulls;
final latestReleaseIdentifiers = apps
.map((app) => app.event.linkedReplaceableEvents.firstOrNull?.$3)
.nonNulls;
if (latestReleaseIdentifiers.isNotEmpty) {
releases = await ref.releases.findAll(
params: {'#d': latestReleaseIdentifiers},
@@ -348,10 +346,16 @@ mixin AppAdapter on Adapter<App> {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tagMap = tagsToMap(map['tags']);
map['developer'] = tagMap['zap']?.firstOrNull ??
(map['pubkey'] != kZapstorePubkey ? map['pubkey'] : null);
map['localApp'] = tagMap['d']!.first;
final tags = map['tags'];
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);
@@ -387,7 +391,7 @@ Future<bool> _isHashMismatch(String path, String hash) async {
extension AppExt on Iterable<App> {
List<App> get sortedByLatest =>
sorted((a, b) => b.createdAt!.compareTo(a.createdAt!));
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
}
// class
+8 -5
View File
@@ -1,5 +1,5 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart';
@@ -9,8 +9,11 @@ import 'package:zapstore/utils/extensions.dart';
part 'app_curation_set.g.dart';
@DataAdapter([NostrAdapter, AppCurationSetAdapter])
class AppCurationSet extends BaseAppCurationSet
class AppCurationSet extends base.AppCurationSet
with DataModelMixin<AppCurationSet> {
@override
Object get id => event.id;
final HasMany<App> apps;
final BelongsTo<User> signer;
@@ -21,7 +24,7 @@ class AppCurationSet extends BaseAppCurationSet
Map<String, dynamic> toJson() => super.toMap();
String get name => content.isNotEmpty ? content : identifier!;
String get name => event.content.isNotEmpty ? event.content : identifier;
}
mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
@@ -45,8 +48,8 @@ mixin AppCurationSetAdapter on Adapter<AppCurationSet> {
DeserializedData<AppCurationSet> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tagMap = tagsToMap(map['tags']);
map['apps'] = tagMap['a'];
final tags = map['tags'];
map['apps'] = base.BaseUtil.getTagSet(tags, 'a');
}
return super.deserialize(data);
}
+10 -7
View File
@@ -1,10 +1,13 @@
import 'package:purplebase/purplebase.dart';
class AppFeedback extends BaseEvent<AppFeedback> {
@override
int get kind => 1011;
AppFeedback({
required String content,
}) : super(content: content);
class AppFeedback extends RegularEvent<AppFeedback> {
AppFeedback.fromJson(super.map) : super.fromJson();
}
class PartialAppFeedback extends RegularPartialEvent<AppFeedback> {
PartialAppFeedback({
required String content,
}) {
event.content = content;
}
}
+6 -12
View File
@@ -1,5 +1,5 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/user.dart';
@@ -8,18 +8,13 @@ import 'package:zapstore/utils/extensions.dart';
part 'file_metadata.g.dart';
@DataAdapter([NostrAdapter, FileMetadataAdapter])
class FileMetadata extends BaseFileMetadata with DataModelMixin<FileMetadata> {
class FileMetadata extends base.FileMetadata with DataModelMixin<FileMetadata> {
final BelongsTo<User> author;
final BelongsTo<Release> release;
final BelongsTo<User> signer;
FileMetadata(
{super.createdAt,
super.content,
super.tags,
required this.author,
required this.release,
required this.signer});
@override
Object? get id => event.id;
FileMetadata.fromJson(super.map)
: author = belongsTo(map['author']),
@@ -30,9 +25,8 @@ class FileMetadata extends BaseFileMetadata with DataModelMixin<FileMetadata> {
Map<String, dynamic> toJson() => super.toMap();
// String? get version => tagMap['version']?.firstOrNull;
int? get versionCode =>
int.tryParse(tagMap['version_code']?.firstOrNull ?? '');
String? get apkSignatureHash => tagMap['apk_signature_hash']?.firstOrNull;
int? get versionCode => int.tryParse(event.getTag('version') ?? '');
String? get apkSignatureHash => event.getTag('apk_signature_hash');
}
mixin FileMetadataAdapter on Adapter<FileMetadata> {
+1 -1
View File
@@ -144,7 +144,7 @@ mixin LocalAppAdapter on Adapter<LocalApp> {
final installedApkSigHash =
sha256.convert(signingCertificateBytes).toString().toLowerCase();
final metadataSigHashes =
app.latestMetadata?.tagMap['apk_signature_hash'] ?? {};
app.latestMetadata?.event.getTagSet('apk_signature_hash') ?? {};
final matches = metadataSigHashes
.any((msh) => msh.toLowerCase() == installedApkSigHash);
if (!matches) {
+22 -8
View File
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' hide Release;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/release.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<T extends DataModelMixin<T>> on Adapter<T> {
RelayMessageNotifier get relay =>
@@ -52,7 +52,10 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
}
int get kind {
return BaseEvent.kindForType(internalType)!;
var pbType = internalType.singularize().capitalize();
pbType =
pbType.replaceFirst('datum', 'data'); // handle FileMetadata edge case
return Event.types[pbType]!.kind;
}
@override
@@ -66,13 +69,13 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
map['signer'] = map['pubkey'];
// ID should be the replaceable link/reference so as to make it replaceable in local db too
final tagMap = tagsToMap(map['tags']);
final isReplaceable = tagMap.containsKey('d');
final tags = map['tags'];
final isReplaceable = BaseUtil.containsTag(tags, 'd');
if (isReplaceable) {
map['id'] = (
map['kind'] as int,
map['pubkey'].toString(),
tagMap['d']?.firstOrNull
BaseUtil.getTag(tags, 'd')!,
).formatted;
}
@@ -80,7 +83,11 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
map.remove('sig');
// Collect models for current kind and included for others
final eventType = BaseEvent.typeForKind(kind)!;
final eventType = Event.types.entries
.firstWhere((e) => e.value.kind == kind)
.key
.pluralize()
.decapitalize();
if (eventType == internalType) {
final newData = super.deserialize(map);
models.addAll(newData.models as Iterable<T>);
@@ -98,6 +105,13 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
return r.isNotEmpty;
}
Iterable<String> existingIds(Iterable<Object> ids) {
final r = db.select(
'SELECT id FROM _keys WHERE id in (${ids.map((e) => '?').join(',')})',
ids.toList());
return r.map((e) => e['id']);
}
@override
Future<List<T>> findAll(
{bool? remote,
@@ -144,7 +158,7 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
Future<void> loadArtifactsAndUsers(Iterable<Release> releases) async {
final metadataIds =
releases.map((r) => r.linkedEvents).nonNulls.expand((_) => _);
releases.map((r) => r.event.linkedEvents).nonNulls.expand((_) => _);
final apps = releases.map((r) => r.app.value).nonNulls;
final userIds = {
+16 -13
View File
@@ -1,6 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/nostr_adapter.dart';
@@ -10,18 +10,21 @@ import 'package:zapstore/utils/extensions.dart';
part 'release.g.dart';
@DataAdapter([NostrAdapter, ReleaseAdapter])
class Release extends BaseRelease with DataModelMixin<Release> {
class Release extends base.Release with DataModelMixin<Release> {
@override
Object? get id => event.id;
final HasMany<FileMetadata> artifacts;
final BelongsTo<App> app;
final BelongsTo<User> signer;
Release(
{super.createdAt,
super.content,
super.tags,
required this.artifacts,
required this.app,
required this.signer});
// Release(
// {super.createdAt,
// super.content,
// super.tags,
// required this.artifacts,
// required this.app,
// required this.signer});
Release.fromJson(super.map)
: app = belongsTo(map['app']),
@@ -38,9 +41,9 @@ mixin ReleaseAdapter on Adapter<Release> {
final list = data is Iterable ? data : [data as Map];
for (final Map<String, dynamic> map in list) {
final tagMap = tagsToMap(map['tags']);
map['app'] = tagMap['a']?.firstOrNull;
map['artifacts'] = tagMap['e']!.toList();
final tags = map['tags'];
map['app'] = base.BaseUtil.getTag(tags, 'a');
map['artifacts'] = base.BaseUtil.getTagSet(tags, 'e').toList();
}
return super.deserialize(data);
}
@@ -48,5 +51,5 @@ mixin ReleaseAdapter on Adapter<Release> {
extension ReleaseExt on Iterable<Release> {
List<Release> get sortedByLatest =>
sorted((a, b) => b.createdAt!.compareTo(a.createdAt!));
sorted((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
}
+6
View File
@@ -11,6 +11,12 @@ class Settings extends DataModel<Settings> {
String get id => '_';
final BelongsTo<User> user = BelongsTo();
final HasMany<User> trustedUsers = HasMany();
SignInMethod? signInMethod;
}
enum SignInMethod {
pubkey,
nip55,
}
mixin SettingsAdapter on Adapter<Settings> {
+13 -2
View File
@@ -12,12 +12,14 @@ mixin _$SettingsAdapter on Adapter<Settings> {
static final Map<String, RelationshipMeta> _kSettingsRelationshipMetas = {
'user': RelationshipMeta<User>(
name: 'user',
inverseName: 'settings',
type: 'users',
kind: 'BelongsTo',
instance: (_) => (_ as Settings).user,
),
'trustedUsers': RelationshipMeta<User>(
name: 'trustedUsers',
inverseName: 'settings',
type: 'users',
kind: 'HasMany',
instance: (_) => (_ as Settings).trustedUsers,
@@ -73,6 +75,15 @@ extension SettingsRelationshipGraphNodeX on RelationshipGraphNode<Settings> {
// JsonSerializableGenerator
// **************************************************************************
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings();
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
..signInMethod =
$enumDecodeNullable(_$SignInMethodEnumMap, json['signInMethod']);
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{};
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
'signInMethod': _$SignInMethodEnumMap[instance.signInMethod],
};
const _$SignInMethodEnumMap = {
SignInMethod.pubkey: 'pubkey',
SignInMethod.nip55: 'nip55',
};
+118 -18
View File
@@ -1,33 +1,130 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
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';
part 'user.g.dart';
@DataAdapter([NostrAdapter, UserAdapter])
class User extends BaseUser with DataModelMixin<User> {
User(
{super.createdAt,
super.tags,
required this.followers,
required this.following});
class User extends base.User with DataModelMixin<User> {
@override
Object? get id => event.id;
Future<void> zap(int amountInSats,
{required base.Event event, String? comment, base.Signer? signer}) async {
final adapter = DataModel.adapterFor(this) as NostrAdapter;
// First ensure connection is available
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
if (nwcConnection == null) {
throw 'No NWC connection';
}
final authorPubkey = event.event.pubkey;
final author = adapter.ref.users.findOneLocalById(authorPubkey);
if (author?.lud16 == null) {
throw 'Recipient has no Lightning address';
}
final amountInMillisats = amountInSats * 1000;
final partialZapRequest = base.PartialZapRequest();
partialZapRequest
..addLinkedEvent(event)
..addLinkedUser(author!)
..relays = kSocialRelays.where((r) => r != 'ndk')
..amount = amountInMillisats
..comment = comment;
final zapRequest = await partialZapRequest.signWith(signer ?? amberSigner,
withPubkey: pubkey);
// Now we fetch the invoice
final lnResponse = await author.fetchLightningAddress();
final recipientAllowsNostr = lnResponse['allowsNostr'];
final recipientPubkey = lnResponse['nostrPubkey'];
// All amounts are in millisats
final recipientMin = lnResponse['minSendable'];
final recipientMax = lnResponse['maxSendable'];
if (recipientAllowsNostr != true && recipientPubkey != null) {
throw 'Recipient does not allow nostr';
}
if ((recipientMin != null && amountInMillisats < recipientMin) ||
(recipientMax != null && amountInMillisats > recipientMax)) {
throw 'Amount not between min and max sendable';
}
final commentLength = lnResponse['commentAllowed'];
comment =
commentLength != null ? comment?.safeSubstring(commentLength) : comment;
var callbackUri = Uri.parse(lnResponse['callback']!);
callbackUri = callbackUri.replace(
queryParameters: {
...callbackUri.queryParameters,
'amount': amountInMillisats.toString(),
if (comment != null) 'comment': comment,
'nostr': jsonEncode(zapRequest.toMap()),
},
);
final invoiceResponse = await http.get(callbackUri);
final invoiceMap = jsonDecode(invoiceResponse.body);
final invoice = invoiceMap['pr'];
if (invoice == null) {
throw 'No invoice';
}
// Pay the invoice via NWC (errors are thrown)
await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
invoice: invoice, timeout: Duration(seconds: 20));
}
Future<Map<String, dynamic>> fetchLightningAddress() async {
final [userName, domainName] = lud16!.split('@');
final lnurl = 'https://$domainName/.well-known/lnurlp/$userName';
final response = await http.get(Uri.parse(lnurl));
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.substring(0, 10)}...';
String get nameOrNpub => name ?? npub.shorten;
}
mixin UserAdapter on NostrAdapter<User> {
@@ -48,7 +145,7 @@ mixin UserAdapter on NostrAdapter<User> {
return [];
}
final request = RelayRequest(
final request = base.RelayRequest(
kinds: {0}, // 3
authors: {...authors},
since: queriedAtMap[authors.join()],
@@ -86,13 +183,8 @@ mixin UserAdapter on NostrAdapter<User> {
if (publicKey.startsWith('npub')) {
publicKey = publicKey.hexKey;
} else {
// If it's not an npub we treat the string as NIP-05
if (!publicKey.contains('@')) {
// If it does not have a @, we treat the string as a domain name
publicKey = '_@$publicKey';
}
} else if (publicKey.contains('@')) {
// If it's not an npub and it has a @ instead, we treat the string as NIP-05
final [username, domain] = publicKey.split('@');
publicKey = await sendRequest<dynamic>(
Uri.parse('https://$domain/.well-known/nostr.json?name=$username'),
@@ -109,14 +201,14 @@ mixin UserAdapter on NostrAdapter<User> {
);
}
final result = await socialRelays.queryRaw(RelayRequest(
final result = await socialRelays.queryRaw(base.RelayRequest(
kinds: {0}, // 3
tags: params ?? {},
authors: {publicKey},
));
final data = await deserializeAsync(result, save: true);
return data.models.firstWhere((e) {
return data.models.firstWhereOrNull((e) {
return e.id == publicKey;
});
}
@@ -155,6 +247,14 @@ mixin UserAdapter on NostrAdapter<User> {
}
}
final signedInUserProvider = Provider<User?>((ref) {
return ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model
?.user
.value;
});
const kZapstorePubkey =
'78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d';
+14
View File
@@ -23,6 +23,13 @@ mixin _$UserAdapter on Adapter<User> {
type: 'users',
kind: 'HasMany',
instance: (_) => (_ as User).followers,
),
'settings': RelationshipMeta<Settings>(
name: 'settings',
inverseName: 'user',
type: 'settings',
kind: 'BelongsTo',
instance: (_) => (_ as User).settings,
)
};
@@ -70,4 +77,11 @@ extension UserRelationshipGraphNodeX on RelationshipGraphNode<User> {
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
RelationshipGraphNode<Settings> get settings {
final meta = _$UserAdapter._kUserRelationshipMetas['settings']
as RelationshipMeta<Settings>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
}
+43
View File
@@ -0,0 +1,43 @@
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, ZapReceiptAdapter])
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);
}
List<ZapReceipt> findByRecipient(
{required String pubkey, required 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]);
return deserializeFromResult(result);
}
}
+72
View File
@@ -0,0 +1,72 @@
// 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>, ZapReceiptAdapter;
final zapReceiptsAdapterProvider = Provider<Adapter<ZapReceipt>>(
(ref) => $ZapReceiptAdapter(ref, InternalHolder(_zapReceiptsFinders)));
extension ZapReceiptAdapterX on Adapter<ZapReceipt> {
NostrAdapter<ZapReceipt> get nostrAdapter => this as NostrAdapter<ZapReceipt>;
ZapReceiptAdapter get zapReceiptAdapter => this as ZapReceiptAdapter;
}
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);
}
}
+13
View File
@@ -6,11 +6,15 @@ import 'package:zapstore/main.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/navigation/router.dart';
import 'package:zapstore/utils/signers.dart';
import 'package:zapstore/widgets/app_curation_container.dart';
AppLifecycleListener? _lifecycleListener;
SharedPreferences? sharedPreferences;
User? anonUser;
final amberSigner = AmberSigner();
final appInitializer = FutureProvider<void>((ref) async {
sharedPreferences = await SharedPreferences.getInstance();
@@ -42,6 +46,15 @@ final appInitializer = FutureProvider<void>((ref) async {
// Preload zapstore's nostr curation set
ref.read(appCurationSetProvider(kNostrCurationSetLink).notifier).fetch();
// Initialize signer
await amberSigner.initialize();
// Set up anon user (pubkey derives from pkSigner secret key)
anonUser ??= User.fromPubkey(
'c86eda2daae768374526bc54903f388d9a866c00740ec8db418d7ef2dca77b5b')
.init()
.saveLocal();
// App-wide listeners
// Register app lifecycle listener
+15 -9
View File
@@ -16,8 +16,10 @@ import 'package:zapstore/widgets/install_button.dart';
import 'package:zapstore/widgets/release_card.dart';
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/wot_container.dart';
import 'package:zapstore/widgets/zap_button.dart';
import 'package:zapstore/widgets/zap_receipts.dart';
class AppDetailScreen extends HookConsumerWidget {
final App model;
@@ -33,11 +35,11 @@ class AppDetailScreen extends HookConsumerWidget {
// TODO: Bug using remote=true in watchOne, use hook for now
final snapshot = useFuture(useMemoized(() async {
final storedApps =
ref.apps.appAdapter.findWhereIdentifierInLocal({model.identifier!});
ref.apps.appAdapter.findWhereIdentifierInLocal({model.identifier});
if (storedApps.firstOrNull?.latestMetadata != null) {
return storedApps.first;
}
return ref.apps.findOne(model.identifier!, remote: true);
return ref.apps.findOne(model.identifier, remote: true);
}));
final state = ref.apps.watchOne(model.id!,
alsoWatch: (_) => {
@@ -57,7 +59,7 @@ class AppDetailScreen extends HookConsumerWidget {
.nonNulls;
return RefreshIndicator(
onRefresh: () => ref.apps.findOne(model.identifier!, remote: true),
onRefresh: () => ref.apps.findOne(model.identifier, remote: true),
child: Column(
children: [
Expanded(
@@ -147,14 +149,17 @@ class AppDetailScreen extends HookConsumerWidget {
),
),
selectable: false,
data: app.content.parseEmojis(),
data: app.event.content.parseEmojis(),
),
Gap(10),
Padding(
padding: const EdgeInsets.only(right: 14),
child: SignerAndDeveloperRow(app: app),
),
Gap(20),
Gap(10),
ZapButton(app: app),
ZapReceipts(app: app),
Gap(10),
Container(
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
@@ -218,7 +223,7 @@ class AppDetailScreen extends HookConsumerWidget {
Gap(10),
Flexible(
child: AutoSizeText(
app.identifier!,
app.identifier,
minFontSize: 12,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -247,7 +252,7 @@ class AppDetailScreen extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${app.latestMetadata!.hash!.substring(0, 6)}...${app.latestMetadata!.hash!.substring(58, 64)}',
app.latestMetadata!.hash!.shorten,
maxLines: 1,
),
Gap(6),
@@ -280,7 +285,8 @@ class AppDetailScreen extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${app.latestMetadata!.apkSignatureHash!.substring(0, 6)}...${app.latestMetadata!.apkSignatureHash!.substring(58, 64)}',
app.latestMetadata!
.apkSignatureHash!.shorten,
maxLines: 1,
),
Gap(6),
+150 -41
View File
@@ -9,15 +9,17 @@ import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/feedback.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/widgets/app_drawer.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/navigation/app_initializer.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/nwc.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/widgets/sign_in_container.dart';
class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key});
@@ -26,12 +28,8 @@ class SettingsScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final systemInfoState = ref.watch(systemInfoNotifierProvider);
final controller = useTextEditingController();
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
.user
.value;
final feedbackController = useTextEditingController();
final signedInUser = ref.watch(signedInUserProvider);
return SingleChildScrollView(
child: Column(
@@ -51,51 +49,69 @@ class SettingsScreen extends HookConsumerWidget {
Text('Comments, suggestions and error reports welcome here.'),
Gap(20),
TextField(
controller: controller,
controller: feedbackController,
maxLines: 10,
),
Gap(20),
if (user == null) LoginContainer(minimal: true),
if (user != null)
if (signedInUser == null) SignInButton(minimal: true),
if (signedInUser != null &&
signedInUser.settings.value!.signInMethod !=
SignInMethod.nip55 ||
!amberSigner.isAvailable)
Text(
'To share feedback, you must be signed in with Amber. You can also message us on nostr!',
style: TextStyle(
color: Colors.red[300], fontWeight: FontWeight.bold)),
if (signedInUser != null &&
signedInUser.settings.value!.signInMethod == SignInMethod.nip55 &&
amberSigner.isAvailable)
AsyncButtonBuilder(
loadingWidget: SizedBox(
width: 14, height: 14, child: CircularProgressIndicator()),
loadingWidget: SmallCircularProgressIndicator(),
onPressed: () async {
if (controller.text.trim().isNotEmpty) {
final text =
'${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]';
final event = AppFeedback(content: text).sign(kI);
if (feedbackController.text.trim().isNotEmpty) {
final signedDirectMessage = await amberSigner.sign(
PartialDirectMessage(
content: feedbackController.text.trim(),
receiver: kZapstorePubkey.npub),
withPubkey: signedInUser.pubkey);
try {
await http.post(Uri.parse('https://relay.zapstore.dev/'),
body: jsonEncode(event.toMap()),
final response = await http.post(
Uri.parse('https://relay.zapstore.dev/'),
body: jsonEncode(signedDirectMessage.toMap()),
headers: {'Content-Type': 'application/json'});
if (response.statusCode >= 400) {
throw Exception(response.body);
}
if (context.mounted) {
context.showInfo('Thank you',
description: 'Message sent successfully');
}
controller.clear();
feedbackController.clear();
} catch (e, stack) {
if (context.mounted) {
context.showError(
title: (e as dynamic).message ?? e.toString(),
context.showError(e.toString(),
description: stack.toString());
}
}
}
},
builder: (context, child, callback, state) {
return switch (state) {
_ => ElevatedButton(
onPressed: callback,
child: child,
),
};
},
child: Text('Send as ${user.nameOrNpub}'),
builder: (context, child, callback, state) =>
ElevatedButton(onPressed: callback, child: child),
child: Text('Send as ${signedInUser.nameOrNpub}'),
),
Gap(40),
Gap(20),
Divider(),
Gap(40),
Gap(20),
Text(
'Nostr Wallet Connect',
style: context.theme.textTheme.headlineLarge!
.copyWith(fontWeight: FontWeight.bold),
),
Gap(20),
NwcContainer(),
Gap(20),
Divider(),
Gap(20),
Text(
'Tools',
style: context.theme.textTheme.headlineLarge!
@@ -180,8 +196,7 @@ class SettingsScreen extends HookConsumerWidget {
}
} catch (e) {
if (context.mounted) {
context.showError(
title: 'Unable to send system info',
context.showError('Unable to send system info',
description: e.toString());
}
}
@@ -196,3 +211,97 @@ class SettingsScreen extends HookConsumerWidget {
);
}
}
class SmallCircularProgressIndicator extends StatelessWidget {
const SmallCircularProgressIndicator({
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(width: 14, height: 14, child: CircularProgressIndicator());
}
}
class NwcContainer extends HookConsumerWidget {
final bool dialogMode;
const NwcContainer({
super.key,
this.dialogMode = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nwcConnection = ref.watch(nwcConnectionProvider);
final controller = useTextEditingController(text: '');
final isTextFieldEmpty = useState(true);
return Padding(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!nwcConnection.hasValue && nwcConnection.isLoading)
SmallCircularProgressIndicator(),
if (nwcConnection.isPresent) Text('Wallet connected'),
if (nwcConnection.hasError)
Text('Error connecting: ${nwcConnection.error}'),
if (!nwcConnection.isPresent && !nwcConnection.isLoading)
TextField(
autocorrect: false,
keyboardType: TextInputType.none,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
decoration: InputDecoration(
hintText: 'nostr+walletconnect://...',
suffixIcon: AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SmallCircularProgressIndicator(),
onPressed: () async {
final nwcUri = controller.text.trim();
await ref
.read(nwcConnectionProvider.notifier)
.connectWallet(nwcUri);
if (dialogMode && context.mounted) {
Navigator.of(context).pop();
}
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Connect'),
),
),
),
if (nwcConnection.isPresent && !dialogMode)
ElevatedButton(
onPressed: () async {
controller.clear();
await ref
.read(nwcConnectionProvider.notifier)
.disconnectWallet();
},
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: Text('Disconnect'),
),
],
),
);
}
}
+76 -24
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:dart_emoji/dart_emoji.dart';
@@ -16,10 +17,7 @@ extension ContextX on BuildContext {
icon: icon ?? Icon(Icons.info),
style: ToastificationStyle.fillColored,
alignment: Alignment.topCenter,
title: Text(
message,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
title: _ToastTitleWidget(message),
description: description != null
? _ToastDescriptionWidget(description: description)
: null,
@@ -29,9 +27,8 @@ extension ContextX on BuildContext {
);
}
void showError(
{required String title,
String? description,
void showError(String title,
{String? description,
Icon? icon,
List<(String, Future<void> Function())> actions = const []}) {
toastification.show(
@@ -40,11 +37,7 @@ extension ContextX on BuildContext {
style: ToastificationStyle.fillColored,
alignment: Alignment.topCenter,
icon: icon ?? Icon(Icons.error),
title: Text(
title,
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
title: _ToastTitleWidget(title),
showProgressBar: false,
closeOnClick: true,
description: description != null
@@ -62,10 +55,7 @@ extension ContextX on BuildContext {
type: ToastificationType.info,
icon: Icon(Icons.info),
style: ToastificationStyle.fillColored,
title: Text(
'Zapstore has a new version available',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
title: _ToastTitleWidget('Zapstore has a new version available'),
description: Text('Tap to go to update screen'),
callbacks: ToastificationCallbacks(
onTap: (item) {
@@ -80,6 +70,21 @@ extension ContextX on BuildContext {
}
}
class _ToastTitleWidget extends StatelessWidget {
final String text;
const _ToastTitleWidget(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
maxLines: 4,
);
}
}
class _ToastDescriptionWidget extends StatelessWidget {
final String? description;
final List<(String, Future<void> Function())> actions;
@@ -91,7 +96,12 @@ class _ToastDescriptionWidget extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(text: TextSpan(text: description)),
RichText(
text: TextSpan(
text: description,
style: TextStyle(fontSize: 16),
),
),
for (final (text, fn) in actions)
ElevatedButton(
style: ElevatedButton.styleFrom(
@@ -108,12 +118,12 @@ class _ToastDescriptionWidget extends StatelessWidget {
}
}
BelongsTo<T> belongsTo<T extends DataModelMixin<T>>(Map<String, dynamic> map) {
return BelongsTo<T>.fromJson(map);
BelongsTo<T> belongsTo<T extends DataModelMixin<T>>(Map<String, dynamic>? map) {
return map != null ? BelongsTo<T>.fromJson(map) : BelongsTo<T>();
}
HasMany<T> hasMany<T extends DataModelMixin<T>>(Map<String, dynamic> map) {
return HasMany<T>.fromJson(map);
HasMany<T> hasMany<T extends DataModelMixin<T>>(Map<String, dynamic>? map) {
return map != null ? HasMany<T>.fromJson(map) : HasMany<T>();
}
final emojiParser = EmojiParser();
@@ -125,8 +135,16 @@ extension StringWidget on String {
});
}
String substringMax(int size) {
return substring(0, min(length, size));
String safeSubstring(int size) {
return substring(0, min(length, size)) + (size < length ? '...' : '');
}
String get shorten {
// npub1wf4puf...43dgh9
if (length < 18) return this;
final leading = substring(0, 9);
final trailing = substring(length - 6, length - 1);
return '$leading...$trailing';
}
String removeParenthesis() {
@@ -134,6 +152,40 @@ extension StringWidget on String {
}
}
const kI = "e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58";
extension TextExt on Text {
Widget get bold {
return Text(data!, style: TextStyle(fontWeight: FontWeight.bold));
}
}
const kZapstoreAppIdentifier = 'dev.zapstore.app';
// stream utils
Stream<List<T>> bufferByTime<T>(Stream<T> source, Duration duration) {
final controller = StreamController<List<T>>();
final buffer = [];
Timer? timer;
// Listen to the source stream
source.listen((data) {
buffer.add(data);
// If there's no active timer, start one
timer ??= Timer(duration, () {
controller.add(List.from(buffer)); // Emit buffered data
buffer.clear(); // Clear the buffer
timer = null; // Reset the timer
});
}, onDone: () {
// Emit remaining items in the buffer when the source is done
if (buffer.isNotEmpty) {
controller.add(List.from(buffer));
}
controller.close(); // Close the controller
});
return controller.stream; // Return the buffered stream
}
+76
View File
@@ -0,0 +1,76 @@
import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart';
import 'package:ndk/ndk.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/user.dart';
const kNwcSecretKey = 'nwc_secret';
class NwcConnectionNotifier extends StateNotifier<AsyncValue<NwcConnection?>> {
Ref ref;
late Ndk ndk;
final _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
NwcConnectionNotifier(this.ref) : super(AsyncLoading()) {
ndk = ref.users.nostrAdapter.socialRelays.ndk!;
// Attempt to load from local storage on initialization
_storage
.read(key: kNwcSecretKey)
.then(connectWallet)
.catchError((e, stack) {
state = AsyncError(e, stack);
});
}
Future<void> connectWallet([String? nwcSecret]) async {
if (nwcSecret == null) {
// We get here when storage has no secret, do nothing
state = AsyncData(null);
return;
}
state = AsyncValue.loading();
// If secret is supplied and a connection is still active,
// it means it is a new connection string so disconnect NWC
if (state.value != null) {
await ndk.nwc.disconnect(state.value!);
}
// Write secret to storage and connect
await _storage.write(key: kNwcSecretKey, value: nwcSecret);
try {
final connection = await ndk.nwc.connect(
nwcSecret,
doGetInfoMethod: false,
);
if (connection.permissions.contains(NwcMethod.PAY_INVOICE.name)) {
state = AsyncValue.data(connection);
} else {
state = AsyncError('No permission to zap', StackTrace.current);
}
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> disconnectWallet() async {
await _storage.delete(key: kNwcSecretKey);
state = AsyncData(null);
}
}
final nwcConnectionProvider =
StateNotifierProvider<NwcConnectionNotifier, AsyncValue<NwcConnection?>>(
(ref) => NwcConnectionNotifier(ref));
extension NwcExt on AsyncValue<NwcConnection?> {
bool get isPresent {
return hasValue && value != null && !hasError;
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'dart:convert';
import 'package:purplebase/purplebase.dart';
import 'package:signer_plugin/signer_plugin.dart';
class AmberSigner extends Signer {
final _signerPlugin = SignerPlugin();
bool isAvailable = false;
@override
Future<AmberSigner> initialize() async {
final hasExternalSigner = await _signerPlugin
.isExternalSignerInstalled('com.greenart7c3.nostrsigner');
if (hasExternalSigner) {
await _signerPlugin.setPackageName('com.greenart7c3.nostrsigner');
isAvailable = true;
}
return this;
}
@override
Future<String?> getPublicKey() async {
final map = await _signerPlugin.getPublicKey();
print('got $map');
return map['npub'] ?? map['result'];
}
@override
Future<E> sign<E extends Event<E>>(PartialEvent<E> partialEvent,
{String? withPubkey}) async {
if (!isAvailable) {
throw Exception("Cannot sign, missing Amber");
}
if (partialEvent is DirectMessage) {
final signedMessage = await _signerPlugin.nip04Encrypt(
partialEvent.event.content,
"",
withPubkey!.npub,
(partialEvent as DirectMessage).receiver.hexKey);
final encryptedContent = signedMessage['result'];
partialEvent.event.content = encryptedContent;
}
// Remove all null fields (Amber otherwise crashes)
final map = {
for (final e in partialEvent.toMap().entries)
if (e.value != null) e.key: e.value
};
final signedMessage =
await _signerPlugin.signEvent(jsonEncode(map), "", withPubkey!);
final signedEvent = jsonDecode(signedMessage['event']);
return Event.getConstructor<E>()!.call(signedEvent);
}
}
final pkSigner = Bip340PrivateKeySigner(
'e593c54f840b32054dcad0fac15d57e4ac6523e31fe26b3087de6b07a2e9af58');
+3 -2
View File
@@ -74,7 +74,7 @@ class AppCard extends HookConsumerWidget {
),
Gap(6),
Text(
app.content.removeMarkdown().parseEmojis(),
app.description.removeMarkdown().parseEmojis(),
style:
TextStyle(fontSize: 14, fontWeight: FontWeight.w300),
overflow: TextOverflow.ellipsis,
@@ -93,7 +93,8 @@ class AppCard extends HookConsumerWidget {
size: 12,
),
if (showDate)
Text(app.latestRelease!.createdAt!.toIso8601String()),
Text(
app.latestRelease!.event.createdAt.toIso8601String()),
if (isUpdate)
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
+6 -6
View File
@@ -3,7 +3,7 @@ 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:purplebase/purplebase.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app_curation_set.dart';
import 'package:zapstore/models/user.dart';
@@ -20,7 +20,7 @@ class AppCurationContainer extends HookConsumerWidget {
final selectedAppCurationSet = ref.watch(_selectedIdProvider);
final appCurationSets = ref.appCurationSets
.findAllLocal()
.sortedBy((s) => s.createdAt!)
.sortedBy((s) => s.event.createdAt)
.reversed
.toList();
@@ -101,9 +101,9 @@ class AppCurationContainer extends HookConsumerWidget {
// Providers
class AppCurationSetNotifier
extends FamilyAsyncNotifier<AppCurationSet, ReplaceableEventLink> {
extends FamilyAsyncNotifier<AppCurationSet, base.ReplaceableEventLink> {
@override
Future<AppCurationSet> build(ReplaceableEventLink arg) async {
Future<AppCurationSet> build(base.ReplaceableEventLink arg) async {
final appCurationSet = ref.appCurationSets.findOneLocalById(arg.formatted)!;
fetch();
return appCurationSet;
@@ -133,9 +133,9 @@ class AppCurationSetNotifier
final appCurationSetProvider = AsyncNotifierProvider.family<
AppCurationSetNotifier,
AppCurationSet,
ReplaceableEventLink>(AppCurationSetNotifier.new);
base.ReplaceableEventLink>(AppCurationSetNotifier.new);
final _selectedIdProvider =
StateProvider<ReplaceableEventLink>((_) => kNostrCurationSetLink);
StateProvider<base.ReplaceableEventLink>((_) => kNostrCurationSetLink);
const kNostrCurationSetLink = (30267, kFranzapPubkey, 'nostr');
-124
View File
@@ -1,124 +0,0 @@
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';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class LoginContainer extends HookConsumerWidget {
final String labelText;
final bool minimal;
LoginContainer(
{super.key,
this.minimal = false,
this.labelText = 'Input your NIP-05 or npub (no nsec!)'});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model
?.user
.value;
final controller = useTextEditingController();
final isTextFieldEmpty = useState(true);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46),
if (!minimal) Gap(10),
if (user != null && !minimal)
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
user.nameOrNpub,
style: TextStyle(fontWeight: FontWeight.bold),
),
Gap(4),
Icon(Icons.verified, color: Colors.lightBlue, size: 18),
],
),
// if (user.following.isNotEmpty)
// Text('${user.following.length} contacts'),
],
),
],
),
if (user == null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(20),
Text(labelText),
TextField(
autocorrect: false,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
decoration: InputDecoration(
hintText: 'your@nip05 or npub',
suffixIcon: AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
onPressed: () async {
return ref.users
.findOne(controller.text.trim())
.then((user) {
ref.settings.findOneLocalById('_')!.user.value = user;
}).catchError((e, stack) {
if (context.mounted) {
context.showError(
title: e.message ?? e.toString(),
description: stack?.toString().substringMax(200));
}
});
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Log in'),
),
),
),
],
),
Gap(5),
if (user != null && !minimal)
ElevatedButton(
onPressed: () async {
ref.settings.findOneLocalById('_')!.user.value = null;
controller.clear();
},
child: Text('Log out'),
),
],
);
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class AuthorContainer extends StatelessWidget {
@@ -44,7 +43,7 @@ class AuthorContainer extends StatelessWidget {
children: [
Text(beforeText),
Text(
user.nameOrNpub.substringMax(24),
user.nameOrNpub,
style: TextStyle(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
+5 -5
View File
@@ -2,12 +2,10 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/widgets/app_drawer.dart';
import 'package:zapstore/widgets/sign_in_container.dart';
class DrawerContainer extends StatelessWidget {
const DrawerContainer({
super.key,
});
const DrawerContainer({super.key});
@override
Widget build(BuildContext context) {
@@ -17,7 +15,9 @@ class DrawerContainer extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: LoginContainer()),
Expanded(
child: SignInButton(),
),
Consumer(
builder: (context, ref, _) {
final state = ref.watch(systemInfoNotifierProvider);
+4 -2
View File
@@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/models/feedback.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/signers.dart';
import 'package:zapstore/utils/system_info.dart';
import 'package:zapstore/utils/theme.dart';
@@ -49,7 +50,7 @@ class ErrorContainer extends HookConsumerWidget {
),
Gap(6),
Text(
stack?.toString().substringMax(80) ?? '',
stack?.toString().safeSubstring(80) ?? '',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
@@ -148,7 +149,8 @@ Future<void> _sendErrorReport(
'info': systemInfo.androidInfo.toString()
};
final event = AppFeedback(content: jsonEncode(map)).sign(kI);
final event =
await pkSigner.sign(PartialAppFeedback(content: jsonEncode(map)));
await http.post(Uri.parse('https://relay.zapstore.dev/'),
body: jsonEncode(event.toMap()),
headers: {'Content-Type': 'application/json'});
+7 -13
View File
@@ -2,12 +2,10 @@ 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:purplebase/purplebase.dart';
import 'package:zapstore/main.data.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/app_drawer.dart';
import 'package:zapstore/widgets/sign_in_container.dart';
import 'package:zapstore/widgets/author_container.dart';
import 'package:zapstore/widgets/wot_container.dart';
@@ -22,11 +20,7 @@ class InstallAlertDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final trustedSignerNotifier = useState(false);
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
.user
.value;
final signedInUser = ref.watch(signedInUserProvider);
return AlertDialog(
elevation: 10,
@@ -50,13 +44,13 @@ class InstallAlertDialog extends HookConsumerWidget {
Gap(20),
if (app.signer.value != null)
WebOfTrustContainer(
fromNpub: user?.npub ?? kFranzapPubkey.npub,
fromNpub: signedInUser?.npub ?? kFranzapPubkey.npub,
toNpub: app.signer.value!.npub,
),
if (user == null)
LoginContainer(
if (signedInUser == null)
SignInButton(
label: 'Sign in to view your web of trust',
minimal: true,
labelText: 'Log in to view your own web of trust',
),
Gap(16),
Row(
+7 -7
View File
@@ -42,7 +42,7 @@ class InstallButton extends HookConsumerWidget {
AppInstallStatus.certificateMismatch =>
null,
AppInstallStatus.updated => () {
packageManager.openApp(app.identifier!);
packageManager.openApp(app.identifier);
},
_ => switch (progress) {
IdleInstallProgress() => () {
@@ -51,8 +51,8 @@ class InstallButton extends HookConsumerWidget {
if (settings.trustedUsers.contains(app.signer.value!)) {
app.install().catchError((e) {
if (context.mounted) {
context.showError(
title: 'Could not install', description: e.message);
context.showError('Could not install',
description: e.message);
}
});
} else {
@@ -64,8 +64,8 @@ class InstallButton extends HookConsumerWidget {
} else if (app.canUpdate) {
app.install().catchError((e) {
if (context.mounted) {
context.showError(
title: 'Could not install', description: e.message);
context.showError('Could not install',
description: e.message);
}
});
} else {
@@ -74,8 +74,8 @@ class InstallButton extends HookConsumerWidget {
},
ErrorInstallProgress(:final e, :final info, :final actions) => () {
// show error and reset state to idle
context.showError(
title: e.message, description: info, actions: actions);
context.showError(e.message,
description: info, actions: actions);
ref.read(installationProgressProvider(app.id!).notifier).state =
IdleInstallProgress();
},
+3 -6
View File
@@ -6,6 +6,7 @@ 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/screens/settings_screen.dart';
import 'package:zapstore/utils/notifier.dart';
import 'package:zapstore/widgets/app_card.dart';
@@ -51,11 +52,7 @@ class LatestReleasesContainer extends HookConsumerWidget {
for (final app in state.value!) AppCard(model: app),
if (state.hasValue)
AsyncButtonBuilder(
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
loadingWidget: SmallCircularProgressIndicator(),
onPressed: () async {
return ref
.read(latestReleasesAppProvider.notifier)
@@ -125,7 +122,7 @@ class LatestReleasesAppNotifier extends PreloadingStateNotifier<List<App>> {
// Set timestamp of oldest, to prepare for next query
if (apps.isNotEmpty) {
_oldestCreatedAt = apps.last.createdAt;
_oldestCreatedAt = apps.last.event.createdAt;
}
return AsyncData(apps);
}
+5 -5
View File
@@ -28,16 +28,16 @@ class ReleaseCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(release.version!,
Text(release.version,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
Gap(10),
release.content.length < 3000
? MarkdownBody(data: release.content)
release.releaseNotes.length < 3000
? MarkdownBody(data: release.releaseNotes)
: Container(
constraints: BoxConstraints(
maxHeight: 300,
),
child: Markdown(data: release.content),
child: Markdown(data: release.releaseNotes),
),
Gap(30),
if (metadata != null)
@@ -54,7 +54,7 @@ class ReleaseCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Date'),
Text(formatter.format(release.createdAt!)),
Text(formatter.format(release.event.createdAt)),
],
),
),
+228
View File
@@ -0,0 +1,228 @@
import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/navigation/app_initializer.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class SignInButton extends ConsumerWidget {
final bool minimal;
final String label;
final bool publicKeyAllowed;
SignInButton({
super.key,
this.minimal = false,
this.label = 'Sign in',
this.publicKeyAllowed = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(signedInUserProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (!minimal) RoundedImage(url: user?.avatarUrl, size: 46),
if (!minimal) Gap(10),
if (user != null)
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
user.nameOrNpub,
// softWrap: true,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold),
),
Gap(4),
Icon(Icons.verified, color: Colors.lightBlue, size: 18),
],
),
// if (user.following.isNotEmpty)
// Text('${user.following.length} contacts'),
],
),
],
),
Gap(10),
ElevatedButton(
onPressed: () async {
if (user == null) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(
label,
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SignInDialogBox(
publicKeyAllowed: publicKeyAllowed,
),
),
);
} else {
ref.settings.findOneLocalById('_')!.user.value = null;
}
},
child: Text(user == null ? label : 'Sign out'),
),
],
);
}
}
class SignInDialogBox extends HookConsumerWidget {
final bool publicKeyAllowed;
const SignInDialogBox({super.key, this.publicKeyAllowed = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final isTextFieldEmpty = useState(true);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (amberSigner.isAvailable)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AsyncButtonBuilder(
loadingWidget: SmallCircularProgressIndicator(),
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
onPressed: () async {
if (!amberSigner.isAvailable) {
Navigator.of(context).pop();
}
final signedInNpub = await amberSigner.getPublicKey();
if (signedInNpub == null) {
if (context.mounted) {
Navigator.of(context).pop();
context.showError('Could not sign in',
description: 'Signer did not respond');
}
return;
}
var user = await ref.users.findOne(signedInNpub);
// If user was not found on relays, we create a
// local user to represent this new npub
user ??= User.fromPubkey(signedInNpub.hexKey)
.init()
.saveLocal();
final settings = ref.settings.findOneLocalById('_')!;
settings.signInMethod = SignInMethod.nip55;
settings.user.value = user;
settings.saveLocal();
if (context.mounted) {
Navigator.of(context).pop(user);
}
},
child: Text('Sign in with Amber'),
),
],
),
if (amberSigner.isAvailable && publicKeyAllowed)
Padding(
padding: const EdgeInsets.all(10),
child: Text('or'),
),
if (publicKeyAllowed)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Input an npub or nostr address\n(read-only):'),
TextField(
autocorrect: false,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
),
Gap(10),
AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SmallCircularProgressIndicator(),
onPressed: () async {
try {
final input = controller.text.trim();
if (input.startsWith('nsec')) {
controller.clear();
throw Exception('Never give away your nsec!');
}
final user = await ref.users.findOne(input);
final settings = ref.settings.findOneLocalById('_')!;
settings.signInMethod = SignInMethod.pubkey;
settings.user.value = user;
settings.saveLocal();
if (context.mounted) {
Navigator.pop(context);
}
} on Exception catch (e, stack) {
if (context.mounted) {
context.showError(e.toString(),
description: stack.toString().safeSubstring(200));
}
}
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Sign in with public key'),
),
],
),
],
),
],
);
}
}
+3 -8
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class UserAvatar extends HookConsumerWidget {
@@ -9,11 +8,7 @@ class UserAvatar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
.user
.value;
return RoundedImage(url: user?.avatarUrl, size: 46);
final signedInUser = ref.watch(signedInUserProvider);
return RoundedImage(url: signedInUser?.avatarUrl, size: 46);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class UsersRichText extends StatelessWidget {
final TextSpan? leadingTextSpan;
final String? trailingText;
final List<User> users;
final User? signedInUser;
final int? maxUsersToDisplay;
final double fontSize;
final bool onlyUseCommaSeparator;
const UsersRichText({
super.key,
this.leadingTextSpan,
this.trailingText,
required this.users,
this.signedInUser,
this.maxUsersToDisplay,
this.fontSize = 15,
this.onlyUseCommaSeparator = false,
});
@override
Widget build(BuildContext context) {
// Move signed in user to head of list
if (users.remove(signedInUser)) {
users.insert(0, signedInUser!);
}
final usersToDisplay = maxUsersToDisplay != null
? users.take(maxUsersToDisplay!).toList()
: users;
final spans = <InlineSpan>[];
if (leadingTextSpan != null) {
spans.add(leadingTextSpan!);
}
usersToDisplay.forEachIndexed((i, user) {
if (user == signedInUser) {
spans.add(TextSpan(
text: leadingTextSpan != null ? ' you' : 'You',
style: TextStyle(fontWeight: FontWeight.bold)));
} else {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
// Add a wrap in order to treat it as a non-breaking block
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.only(right: 4),
child: RoundedImage(url: user.avatarUrl, size: 20),
),
Text(user.nameOrNpub,
style: TextStyle(
fontSize: fontSize, fontWeight: FontWeight.bold)),
],
),
),
);
}
// Before the last span
if (i < usersToDisplay.length - 2) {
spans.add(TextSpan(text: ', '));
}
if (i == usersToDisplay.length - 2) {
// If onlyUseCommaSeparator, or users length goes over max to display,
// then add a comma because we'll later add "and others"
if (onlyUseCommaSeparator || usersToDisplay.length < users.length) {
spans.add(TextSpan(text: ', '));
} else {
spans.add(TextSpan(text: ' and '));
}
}
// Last span
if (i == usersToDisplay.length - 1) {
if (usersToDisplay.length < users.length) {
final remainingUsersLength = users.length - usersToDisplay.length;
spans.add(TextSpan(text: ' and $remainingUsersLength others'));
}
if (trailingText != null) {
spans.add(TextSpan(text: trailingText));
}
}
});
return Text.rich(
TextSpan(style: TextStyle(fontSize: fontSize), children: spans),
);
}
}
+9 -71
View File
@@ -1,12 +1,10 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/rounded_image.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
class WebOfTrustContainer extends HookConsumerWidget {
const WebOfTrustContainer({
@@ -22,26 +20,21 @@ class WebOfTrustContainer extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final data =
ref.watch(followsWhoFollowProvider((from: fromNpub, to: toNpub)));
final signedInUser = ref.watch(signedInUserProvider);
return switch (data) {
AsyncData<List<User>>(value: final trustedUsers) => Builder(
builder: (context) {
final hasUser =
trustedUsers.firstWhereOrNull((u) => u.npub == fromNpub) !=
null;
final trustedUsersWithoutLoggedInUser =
trustedUsers.where((u) => u.npub != fromNpub).toList();
if (trustedUsersWithoutLoggedInUser.isEmpty) {
if (trustedUsers.isEmpty) {
return Text(
'No trusted users between you and the signer. (This may be a service error)');
}
return UsersRichText(
preSpan: hasUser && fromNpub != kFranzapPubkey.npub
? TextSpan(text: 'You, ')
: null,
users: trustedUsers,
signedInUser: signedInUser,
onlyUseCommaSeparator: true,
trailingText: ' and others follow this signer on nostr.',
users: trustedUsersWithoutLoggedInUser,
);
},
),
@@ -55,11 +48,7 @@ class WebOfTrustContainer extends HookConsumerWidget {
children: [
Text('Loading web of trust...'),
Gap(10),
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
SmallCircularProgressIndicator(),
],
),
),
@@ -68,60 +57,9 @@ class WebOfTrustContainer extends HookConsumerWidget {
}
}
class UsersRichText extends StatelessWidget {
const UsersRichText({
super.key,
this.preSpan,
this.trailingText,
required this.users,
});
final TextSpan? preSpan;
final String? trailingText;
final List<User> users;
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
children: [
if (preSpan != null) preSpan!,
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),
Text(
' ${user.nameOrNpub}',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(users.indexOf(user) == users.length - 1
? ''
: (users.indexOf(user) == users.length - 2
? ' and '
: ', ')),
],
),
),
if (users.indexOf(user) == users.length - 1)
TextSpan(text: trailingText, style: TextStyle(fontSize: 15))
],
),
],
),
);
}
}
final followsWhoFollowProvider = FutureProvider.autoDispose
.family<List<User>, ({String from, String to})>((ref, arg) async {
final _ =
ref.settings.watchOne('_', alsoWatch: (_) => {_.user}).model!.user.value;
final _ = ref.watch(signedInUserProvider);
final users = await ref.users.userAdapter.getTrusted(arg.from, arg.to);
return users.toSet().toList();
});
+178
View File
@@ -0,0 +1,178 @@
import 'package:async_button_builder/async_button_builder.dart';
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/navigation/app_initializer.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/signers.dart';
import 'package:zapstore/widgets/sign_in_container.dart';
import '../utils/nwc.dart';
class ZapButton extends HookConsumerWidget {
ZapButton({
super.key,
required this.app,
});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final nwcConnection = ref.watch(nwcConnectionProvider);
final amountController = TextEditingController();
final commentController = TextEditingController();
if (app.developer.value?.lud16 == null) {
return Container();
}
return AsyncButtonBuilder(
loadingWidget: Text('⚡️ Zapping...').bold,
builder: (context, child, callback, state) => ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 60, 60, 54),
disabledBackgroundColor: Colors.grey[600],
),
child: child,
),
onPressed: nwcConnection.isLoading
? null
: () async {
if (!nwcConnection.isPresent) {
await showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (_) => AlertDialog(
title: Text('Connect your wallet').bold,
content: NwcContainer(dialogMode: true),
),
);
if (!ref.read(nwcConnectionProvider).isPresent) {
return;
}
}
final valueRecord = await showDialog<(int, String)>(
// ignore: use_build_context_synchronously
context: context,
builder: (context) {
return AlertDialog(
title: Text('Zap this release').bold,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Consumer(
builder: (context, ref, _) {
final signedInUser =
ref.watch(signedInUserProvider);
if (signedInUser == null) {
return Column(
children: [
Text(
'⚠️ If you do not sign in, you will be zapping anonymously'),
SignInButton(
publicKeyAllowed: false,
minimal: true,
),
],
);
}
return Container();
},
),
TextField(
controller: amountController,
keyboardType: TextInputType.number,
decoration:
InputDecoration(hintText: 'Enter amount in sats'),
),
Gap(20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () => amountController.text = '21',
child: Text('⚡️21'),
),
Gap(3),
ElevatedButton(
onPressed: () => amountController.text = '210',
child: Text('⚡️210'),
),
Gap(3),
ElevatedButton(
onPressed: () => amountController.text = '2100',
child: Text('⚡️2100'),
),
],
),
TextField(
controller: commentController,
decoration: InputDecoration(
hintText: 'Add a comment (optional)'),
),
],
),
actions: [
TextButton(
child: Text('Cancel'),
onPressed: () => Navigator.of(context).pop(),
),
TextButton(
child: Text('Zap').bold,
onPressed: () {
final record = (
int.tryParse(amountController.text) ?? 0,
commentController.text
);
Navigator.of(context).pop(record);
},
),
],
);
},
);
if (valueRecord == null) {
return;
}
final (amount, comment) = valueRecord;
if (amount > 0) {
try {
// Reload user as we are in a callback and it could have changed
final signedInUser =
ref.settings.findOneLocalById('_')!.user.value;
if (signedInUser != null) {
await signedInUser.zap(amount,
event: app.latestMetadata!, comment: comment);
} else {
await anonUser!.zap(amount,
event: app.latestMetadata!,
signer: pkSigner,
comment: comment);
}
// ignore: use_build_context_synchronously
context.showInfo('$amount sat${amount > 1 ? 's' : ''} sent!',
description: 'Payment was successful');
} catch (e) {
// ignore: use_build_context_synchronously
context.showError('Unable to zap', description: e.toString());
}
}
},
child: Text(
'⚡️ Zap this release',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
),
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.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';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
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/utils/extensions.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
class ZapReceipts extends HookConsumerWidget {
ZapReceipts({
super.key,
required this.app,
});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInUser = ref.watch(signedInUserProvider);
// NOTE: These watchers will become more efficient
ref.users.watchAll();
final fileMetadata = app.latestMetadata;
if (fileMetadata == null) {
return Container();
}
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 zapperIds = receipts.map((r) => r.senderPubkey).toSet();
final zappers = ref.users.findManyLocalByIds(zapperIds);
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: UsersRichText(
leadingTextSpan: TextSpan(
style: TextStyle(fontSize: 16),
children: [
TextSpan(text: '⚡'),
TextSpan(
text: ' $totalAmountInSats sats ',
style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'zapped by'),
],
),
users: zappers,
signedInUser: signedInUser,
maxUsersToDisplay: 5,
),
);
}
}
class ZapReceiptsNotifier extends StateNotifier<AsyncValue<Set<ZapReceipt>>> {
Ref ref;
final FileMetadata fileMetadata;
StreamSubscription<List<Nip01Event>>? sub;
ZapReceiptsNotifier(this.ref, this.fileMetadata) : super(AsyncLoading()) {
final adapter = ref.users.nostrAdapter;
final developerPubkey =
fileMetadata.event.getTag('zap') ?? fileMetadata.event.pubkey;
// If it is a Zapstore-signed release from the indexer, return
if (developerPubkey == kZapstorePubkey &&
fileMetadata.release.value!.app.value!.identifier !=
kZapstoreAppIdentifier) {
state = AsyncData({});
return;
}
final localZapReceipts = ref.zapReceipts.zapReceiptAdapter
.findByRecipient(
pubkey: developerPubkey, eventId: fileMetadata.id!.toString())
.toSet();
_loadZappers(localZapReceipts);
state = AsyncData(localZapReceipts);
// NOTE: ideally this caching stuff should be handled by purplebase
final latestReceiptTimestamp = localZapReceipts
.sortedBy((z) => z.event.createdAt)
.lastOrNull
?.event
.createdAt
.millisecondsSinceEpoch;
final receiptsResponse = adapter.socialRelays.ndk!.requests.subscription(
filters: [
Filter(
kinds: [9735],
since: latestReceiptTimestamp != null
? latestReceiptTimestamp ~/ 1000 + 1
: null,
eTags: [fileMetadata.event.id],
pTags: [developerPubkey],
)
],
);
sub = bufferByTime(receiptsResponse.stream, Duration(seconds: 1))
.listen((data) async {
final zapReceipts =
data.map((r) => ZapReceipt.fromJson(r.toJson()).init().saveLocal());
await _loadZappers(zapReceipts);
state = AsyncData({if (state.hasValue) ...state.value!, ...zapReceipts});
});
}
// Load senders - this tedious work will be handled by purplebase at some point
Future<void> _loadZappers(Iterable<ZapReceipt> receipts) async {
// If we do not have the users locally, trigger a remote fetch
final receiptsPubkeys = receipts.map((r) => r.senderPubkey).toSet();
final existingPubkeysLocally =
ref.users.nostrAdapter.existingIds(receiptsPubkeys).toSet();
final missingPubkeysLocally =
receiptsPubkeys.difference(existingPubkeysLocally);
if (missingPubkeysLocally.isNotEmpty) {
final fetchedSenders =
await ref.users.findAll(params: {'authors': missingPubkeysLocally});
final stillMissingPubkeys = missingPubkeysLocally
.toSet()
.difference(fetchedSenders.map((s) => s.id!.toString()).toSet());
for (final pubkey in stillMissingPubkeys) {
// If relays do not have it, create a dummy local user
User.fromPubkey(pubkey).init().saveLocal();
}
}
}
@override
void dispose() {
sub?.cancel();
super.dispose();
}
}
final zapReceiptsNotifier = StateNotifierProvider.autoDispose
.family<ZapReceiptsNotifier, AsyncValue<Set<ZapReceipt>>, FileMetadata>(
ZapReceiptsNotifier.new);
@@ -6,11 +6,15 @@
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <gtk/gtk_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
+1
View File
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
gtk
sqlite3_flutter_libs
url_launcher_linux
@@ -7,6 +7,7 @@ import Foundation
import app_links
import device_info_plus
import flutter_secure_storage_macos
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
@@ -16,6 +17,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
+104 -33
View File
@@ -130,10 +130,10 @@ packages:
dependency: transitive
description:
name: bip340
sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87"
sha256: b7bcd70a860e605046006adaa72bc4f7453f4d31d7ba74a4ad9d5de387a0fc0b
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.3.0"
boolean_selector:
dependency: transitive
description:
@@ -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:
@@ -310,14 +318,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.7"
decimal:
dependency: transitive
description:
name: decimal
sha256: "24a261d5d5c87e86c7651c417a5dbdf8bcd7080dd592533910e8d0505a279f21"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
device_info_plus:
dependency: "direct main"
description:
@@ -342,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:
@@ -452,6 +460,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0"
url: "https://pub.dev"
source: hosted
version: "9.2.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -526,6 +582,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
hex:
dependency: transitive
description:
name: hex
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
hooks_riverpod:
dependency: "direct main"
description:
@@ -558,14 +622,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
humanizer:
dependency: transitive
description:
name: humanizer
sha256: "08728a4b6d62accd7d09e668bd54e81e6e09a82c8cfda30553224b3eb868d4f2"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
icons_launcher:
dependency: "direct dev"
description:
@@ -626,10 +682,10 @@ packages:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.7.1"
version: "0.6.7"
json_annotation:
dependency: "direct main"
description:
@@ -678,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:
@@ -742,6 +806,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
ndk:
dependency: "direct main"
description:
name: ndk
sha256: "07c284a14e7800513743a84c911a546dcfcdc0c0ce2def4895c4e4c2286f159e"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
octo_image:
dependency: transitive
description:
@@ -945,11 +1017,9 @@ packages:
purplebase:
dependency: "direct main"
description:
path: "."
ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a"
resolved-ref: "7f0eefd1fd1bcbc947123dc377183dbae58ea97a"
url: "https://github.com/purplebase/purplebase"
source: git
path: "../purplebase"
relative: true
source: path
version: "0.2.0"
quiver:
dependency: transitive
@@ -959,14 +1029,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
rational:
dependency: transitive
description:
name: rational
sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
url: "https://pub.dev"
source: hosted
version: "2.2.3"
remove_markdown:
dependency: "direct main"
description:
@@ -1063,6 +1125,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
signer_plugin:
dependency: "direct main"
description:
path: "."
ref: baa5b2fcf7ba8db606a76794582348a1403438b0
resolved-ref: baa5b2fcf7ba8db606a76794582348a1403438b0
url: "https://github.com/chebizarro/flutter-signer-plugin"
source: git
version: "0.0.1"
skeletonizer:
dependency: "direct main"
description:
+11 -4
View File
@@ -35,6 +35,7 @@ dependencies:
intl: ^0.18.1
device_info_plus: ^11.2.0
flutter_layout_grid: ^2.0.6
signer_plugin: ^1.0.0
skeletonizer: ^1.1.2+1
flutter_phoenix: ^1.1.1
percent_indicator: ^4.2.3
@@ -51,17 +52,23 @@ dependencies:
background_downloader: ^8.8.0
mutex: ^3.1.0
easy_image_viewer: ^1.5.1
flutter_secure_storage: ^9.2.2
ndk: ^0.2.4
dependency_overrides:
purplebase:
# path: ../purplebase
git:
url: https://github.com/purplebase/purplebase
ref: 7f0eefd1fd1bcbc947123dc377183dbae58ea97a
path: ../purplebase
# git:
# url: https://github.com/purplebase/purplebase
# ref: a6a4dbf97faa23f827dbe8158cb110e961827ddc
flutter_data:
git:
url: https://github.com/zapstore/flutter_data
ref: d7376cf1eac8e9c563d4c6eed8e567adc877415c
signer_plugin:
git:
url: https://github.com/chebizarro/flutter-signer-plugin
ref: baa5b2fcf7ba8db606a76794582348a1403438b0
dev_dependencies:
flutter_test: