Fix schemaFilters for stacks, abbreviations, zaps in user_screen

This commit is contained in:
franzap
2025-12-24 15:27:04 -03:00
parent fb611a65d7
commit 984a42692a
8 changed files with 197 additions and 131 deletions
+52 -26
View File
@@ -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,
),
);
}
}
+22
View File
@@ -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)}';
}
}
+2 -1
View File
@@ -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,
+2 -8
View File
@@ -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,
+71 -75
View File
@@ -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(
+17 -5
View File
@@ -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>>({});
+1 -7
View File
@@ -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'),
),
],
+30 -9
View File
@@ -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()