mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Fix schemaFilters for stacks, abbreviations, zaps in user_screen
This commit is contained in:
@@ -4,8 +4,6 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../theme.dart';
|
||||
import '../widgets/common/note_parser.dart';
|
||||
import '../widgets/common/profile_avatar.dart';
|
||||
@@ -66,11 +64,11 @@ class UserScreen extends HookConsumerWidget {
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
schemaFilter: appStackEventFilter,
|
||||
subscriptionPrefix: 'user-stacks',
|
||||
schemaFilter: appStackEventFilter,
|
||||
),
|
||||
);
|
||||
final stacks = appStacksState.models
|
||||
final stacks = appStacksState.models.toList()
|
||||
..sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
|
||||
return Scaffold(
|
||||
@@ -158,8 +156,9 @@ class _UserHeader extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile?.nameOrNpub ??
|
||||
'${Utils.encodeShareableFromString(pubkey, type: 'npub').substring(0, 12)}...',
|
||||
profile?.nameOrNpub.abbreviateNpub() ??
|
||||
Utils.encodeShareableFromString(pubkey, type: 'npub')
|
||||
.abbreviateNpub(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -206,11 +205,7 @@ class _UserZapsList extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (apps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Create combined tag maps for zaps on apps and their metadata
|
||||
// Collect addressable tags for apps and metadata IDs separately
|
||||
final allAppTags = <String, Set<String>>{};
|
||||
final metadataIds = <String>{};
|
||||
|
||||
@@ -226,34 +221,65 @@ class _UserZapsList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final zapTags = <String, Set<String>>{
|
||||
...allAppTags,
|
||||
if (metadataIds.isNotEmpty) '#e': metadataIds,
|
||||
};
|
||||
|
||||
if (zapTags.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final zapsState = ref.watch(
|
||||
// Query zaps on apps (via #a tag) - separate from metadata zaps
|
||||
final appZapsState = ref.watch(
|
||||
query<Zap>(
|
||||
tags: zapTags,
|
||||
tags: allAppTags,
|
||||
source: const LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.author, zap.zapRequest},
|
||||
and: (zap) => {zap.zapRequest},
|
||||
andSource: const LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'user-zaps',
|
||||
subscriptionPrefix: 'user-app-zaps',
|
||||
),
|
||||
);
|
||||
|
||||
final allZaps = zapsState.models;
|
||||
// Query zaps on metadata (via #e tag) - separate query
|
||||
final metadataZapsState = ref.watch(
|
||||
query<Zap>(
|
||||
tags: {'#e': metadataIds},
|
||||
source: const LocalAndRemoteSource(relays: 'social'),
|
||||
and: (zap) => {zap.zapRequest},
|
||||
andSource: const LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
subscriptionPrefix: 'user-metadata-zaps',
|
||||
),
|
||||
);
|
||||
|
||||
// Combine zaps from both queries
|
||||
final allZaps = {...appZapsState.models, ...metadataZapsState.models};
|
||||
|
||||
if (allZaps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Collect pubkeys from zaps and zapRequests for profile query
|
||||
final zapperPubkeys = <String>{};
|
||||
for (final zap in allZaps) {
|
||||
zapperPubkeys.add(zap.event.pubkey);
|
||||
final zapRequest = zap.zapRequest.value;
|
||||
if (zapRequest != null) {
|
||||
zapperPubkeys.add(zapRequest.event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Query profiles separately with cachedFor
|
||||
final profilesState = ref.watch(
|
||||
query<Profile>(
|
||||
authors: zapperPubkeys,
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'social', 'vertex'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
final profilesMap = {
|
||||
for (final p in profilesState.models) p.pubkey: p,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: ZappersHorizontalList(zaps: allZaps.toList()),
|
||||
child: ZappersHorizontalList(
|
||||
zaps: allZaps.toList(),
|
||||
profilesMap: profilesMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +108,25 @@ extension FileMetadataExt on FileMetadata {
|
||||
return apkSignatureHash != null ? {apkSignatureHash!} : {};
|
||||
}
|
||||
}
|
||||
|
||||
/// String abbreviation utilities
|
||||
extension StringAbbreviationExt on String {
|
||||
/// Abbreviates a string to show beginning and end with ellipsis
|
||||
/// Default: shows first 6 and last 6 chars if string is longer than 12
|
||||
String abbreviate({int start = 6, int end = 6}) {
|
||||
final t = trim();
|
||||
final minLength = start + end;
|
||||
if (t.length <= minLength) return t;
|
||||
return '${t.substring(0, start)}...${t.substring(t.length - end)}';
|
||||
}
|
||||
|
||||
/// Abbreviates npub identifiers to "npub1abc...xyz" format
|
||||
/// Shows first 9 chars (npub1 + 4 chars) and last 7 chars
|
||||
/// Returns original string if not an npub identifier
|
||||
String abbreviateNpub() {
|
||||
if (!startsWith('npub1') || length < 20) {
|
||||
return this;
|
||||
}
|
||||
return '${substring(0, 9)}...${substring(length - 7)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ class AppCard extends HookConsumerWidget {
|
||||
);
|
||||
final publisherStyle = byStyle?.copyWith(fontWeight: FontWeight.w600);
|
||||
final avatarSize = context.textTheme.bodyMedium!.fontSize! * 1.4;
|
||||
final displayName = publisher.nameOrNpub.abbreviateNpub();
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
@@ -281,7 +282,7 @@ class AppCard extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: publisher.nameOrNpub, style: publisherStyle),
|
||||
TextSpan(text: displayName, style: publisherStyle),
|
||||
],
|
||||
),
|
||||
softWrap: true,
|
||||
|
||||
@@ -78,7 +78,7 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
if (fileMetadata?.hash != null) {
|
||||
final full = fileMetadata!.hash;
|
||||
rows.add(
|
||||
_InfoRow(label: 'File hash', value: _abbr(full), copyValue: full),
|
||||
_InfoRow(label: 'File hash', value: full.abbreviate(), copyValue: full),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
rows.add(
|
||||
_InfoRow(
|
||||
label: 'Certificate hash',
|
||||
value: _abbr(full),
|
||||
value: full.abbreviate(),
|
||||
copyValue: full,
|
||||
),
|
||||
);
|
||||
@@ -127,12 +127,6 @@ class AppInfoTable extends HookConsumerWidget {
|
||||
return rows;
|
||||
}
|
||||
|
||||
String _abbr(String v) {
|
||||
final t = v.trim();
|
||||
if (t.length <= 12) return t;
|
||||
return '${t.substring(0, 6)}...${t.substring(t.length - 6)}';
|
||||
}
|
||||
|
||||
Widget _buildVersionCodePills(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:models/models.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../utils/extensions.dart';
|
||||
import '../utils/url_utils.dart';
|
||||
import '../theme.dart';
|
||||
@@ -37,8 +36,8 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
schemaFilter: appStackEventFilter,
|
||||
subscriptionPrefix: 'app-stack',
|
||||
schemaFilter: appStackEventFilter,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -60,18 +59,18 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
final followingPubkeys =
|
||||
contactListState?.models.firstOrNull?.followingPubkeys;
|
||||
|
||||
final rawStacks = switch (appStacksState) {
|
||||
StorageData(:final models) => models,
|
||||
final stacks = switch (appStacksState) {
|
||||
StorageData(:final models) => models.toList(),
|
||||
_ => <AppStack>[],
|
||||
};
|
||||
|
||||
if (rawStacks.isEmpty) {
|
||||
if (stacks.isEmpty) {
|
||||
return _buildSkeleton(context);
|
||||
}
|
||||
|
||||
// Sort stacks: followed profiles first (or franzap if not signed in), then by recency
|
||||
final stacks = _sortStacks(
|
||||
rawStacks,
|
||||
final sortedStacks = _sortStacks(
|
||||
stacks,
|
||||
signedInPubkey: signedInPubkey,
|
||||
followingPubkeys: followingPubkeys,
|
||||
);
|
||||
@@ -85,10 +84,11 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < stacks.length; i += 2)
|
||||
for (int i = 0; i < sortedStacks.length; i += 2)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i == stacks.length - 2 || i == stacks.length - 1
|
||||
right:
|
||||
i == sortedStacks.length - 2 || i == sortedStacks.length - 1
|
||||
? 0
|
||||
: 10,
|
||||
),
|
||||
@@ -97,10 +97,10 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_StackCard(stack: stacks[i]),
|
||||
if (i + 1 < stacks.length) ...[
|
||||
_StackCard(stack: sortedStacks[i]),
|
||||
if (i + 1 < sortedStacks.length) ...[
|
||||
const SizedBox(height: 10),
|
||||
_StackCard(stack: stacks[i + 1]),
|
||||
_StackCard(stack: sortedStacks[i + 1]),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -174,23 +174,20 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < 6; i += 2)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: i == 4 ? 0 : 10),
|
||||
child: SizedBox(
|
||||
width: 160,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _SkeletonStackCard(),
|
||||
if (i + 1 < 6) ...[
|
||||
const SizedBox(height: 10),
|
||||
const _SkeletonStackCard(),
|
||||
],
|
||||
],
|
||||
),
|
||||
for (int column = 0; column < 3; column++) ...[
|
||||
if (column > 0) const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_SkeletonStackCard(),
|
||||
SizedBox(height: 10),
|
||||
_SkeletonStackCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -214,55 +211,36 @@ class _SkeletonStackCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Stack name placeholder - increased to match titleMedium size
|
||||
Container(
|
||||
height: 22,
|
||||
height: 18,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Author row placeholder
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
height: 16,
|
||||
width: 70,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Horizontal icons row placeholder - ensure proper sizing
|
||||
SizedBox(
|
||||
height: 36, // Explicit height to match actual icon row
|
||||
child: Row(
|
||||
children: List.generate(
|
||||
4,
|
||||
(index) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
Container(
|
||||
height: 14,
|
||||
width: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkSkeletonBase,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -300,14 +278,32 @@ class _StackCard extends HookConsumerWidget {
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Get shuffled apps for the icons row - shuffle only once
|
||||
final apps = stack.apps.toList();
|
||||
final totalApps = apps.length;
|
||||
final previewApps = useMemoized(() {
|
||||
final shuffled = List<App>.from(apps)..shuffle(Random());
|
||||
// Show 3 if there are more than 4, otherwise show up to 4
|
||||
final stackApps = stack.apps.toList();
|
||||
final totalApps = stackApps.length;
|
||||
|
||||
// Pick preview apps deterministically (stable UI) and only fetch those from AppCatalog
|
||||
final previewBaseApps = (() {
|
||||
final shuffled = List<App>.from(stackApps)
|
||||
..shuffle(Random(stack.id.hashCode));
|
||||
return shuffled.take(totalApps > 4 ? 3 : 4).toList();
|
||||
}, [stack.id]);
|
||||
})();
|
||||
final previewIdentifiers = previewBaseApps
|
||||
.map((app) => app.identifier)
|
||||
.toSet();
|
||||
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {'#d': previewIdentifiers},
|
||||
limit: previewIdentifiers.length,
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-stack-preview-apps-${stack.id}',
|
||||
),
|
||||
);
|
||||
|
||||
final appsMap = {for (final app in appsState.models) app.identifier: app};
|
||||
final previewApps = previewBaseApps
|
||||
.map((app) => appsMap[app.identifier] ?? app)
|
||||
.toList();
|
||||
|
||||
// Styling like app_card.dart (slightly larger for visibility)
|
||||
final profileStyle = context.textTheme.bodySmall?.copyWith(
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/auth_widgets.dart';
|
||||
@@ -285,14 +284,27 @@ class _AddToStackDialogSignedIn extends HookConsumerWidget {
|
||||
and: (stack) => {stack.apps},
|
||||
source: const LocalAndRemoteSource(relays: 'social', stream: false),
|
||||
andSource: const LocalSource(),
|
||||
schemaFilter: appStackEventFilter,
|
||||
subscriptionPrefix: 'user-stacks-dialog',
|
||||
// Filter at query level: exclude saved-apps stack and stacks with no app references
|
||||
schemaFilter: (event) {
|
||||
final tags = event['tags'] as List?;
|
||||
if (tags == null) return false;
|
||||
// Check d tag is not the bookmarks identifier
|
||||
final dTag = tags.firstWhere(
|
||||
(t) => t is List && t.isNotEmpty && t[0] == 'd',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (dTag != null && dTag[1] == kAppBookmarksIdentifier) return false;
|
||||
// Check has at least one 'a' tag (app reference)
|
||||
final hasAppRef = tags.any(
|
||||
(t) => t is List && t.isNotEmpty && t[0] == 'a',
|
||||
);
|
||||
return hasAppRef;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final publicStacks = publicStacksState.models
|
||||
.where((stack) => stack.identifier != kAppBookmarksIdentifier)
|
||||
.toList();
|
||||
final publicStacks = publicStacksState.models.toList();
|
||||
|
||||
// Check which stacks contain this app
|
||||
final selectedCollections = useState<Set<String>>({});
|
||||
|
||||
@@ -820,12 +820,6 @@ class InstallButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _abbr(String v) {
|
||||
final t = v.trim();
|
||||
if (t.length <= 12) return t;
|
||||
return '${t.substring(0, 6)}...${t.substring(t.length - 6)}';
|
||||
}
|
||||
|
||||
Widget _buildCompactHashRow(
|
||||
BuildContext context,
|
||||
String versionLabel,
|
||||
@@ -844,7 +838,7 @@ class InstallButton extends ConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextSpan(
|
||||
text: _abbr(hash),
|
||||
text: hash.abbreviate(),
|
||||
style: TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -73,28 +73,49 @@ class ZapButton extends HookConsumerWidget {
|
||||
|
||||
/// Horizontal list of zappers with their profile avatars and amounts
|
||||
class ZappersHorizontalList extends StatelessWidget {
|
||||
const ZappersHorizontalList({super.key, required this.zaps});
|
||||
const ZappersHorizontalList({
|
||||
super.key,
|
||||
required this.zaps,
|
||||
this.profilesMap,
|
||||
});
|
||||
|
||||
final List<Zap> zaps;
|
||||
|
||||
/// Optional map of pubkey -> Profile. If not provided, falls back to
|
||||
/// relationship-loaded profiles (legacy behavior).
|
||||
final Map<String, Profile>? profilesMap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.8);
|
||||
|
||||
// Group sats per zapper (prefer zapRequest.author over wallet author)
|
||||
// Group sats per zapper (prefer zapRequest author over wallet author)
|
||||
final Map<String, int> satsPerPubkey = <String, int>{};
|
||||
final Map<String, Profile> profileByPubkey = <String, Profile>{};
|
||||
|
||||
for (final zap in zaps) {
|
||||
final requestAuthor = zap.zapRequest.value?.author.value;
|
||||
final walletAuthor = zap.author.value;
|
||||
final chosenAuthor = requestAuthor ?? walletAuthor;
|
||||
final pubkey = chosenAuthor?.pubkey;
|
||||
if (pubkey == null || chosenAuthor == null) continue;
|
||||
// Get pubkeys - prefer zapRequest author over wallet author
|
||||
final requestPubkey = zap.zapRequest.value?.event.pubkey;
|
||||
final walletPubkey = zap.event.pubkey;
|
||||
final chosenPubkey = requestPubkey ?? walletPubkey;
|
||||
|
||||
satsPerPubkey[pubkey] = (satsPerPubkey[pubkey] ?? 0) + zap.amount;
|
||||
profileByPubkey[pubkey] = chosenAuthor;
|
||||
// Get profile from provided map or fall back to relationship
|
||||
Profile? chosenProfile;
|
||||
if (profilesMap != null) {
|
||||
chosenProfile = profilesMap![chosenPubkey];
|
||||
} else {
|
||||
// Legacy: try to get from relationship
|
||||
final requestAuthor = zap.zapRequest.value?.author.value;
|
||||
final walletAuthor = zap.author.value;
|
||||
chosenProfile = requestAuthor ?? walletAuthor;
|
||||
}
|
||||
|
||||
satsPerPubkey[chosenPubkey] =
|
||||
(satsPerPubkey[chosenPubkey] ?? 0) + zap.amount;
|
||||
if (chosenProfile != null) {
|
||||
profileByPubkey[chosenPubkey] = chosenProfile;
|
||||
}
|
||||
}
|
||||
|
||||
final entries = satsPerPubkey.entries.toList()
|
||||
|
||||
Reference in New Issue
Block a user