diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index bf38d20..da4b10a 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -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 = >{}; final metadataIds = {}; @@ -226,34 +221,65 @@ class _UserZapsList extends HookConsumerWidget { } } - final zapTags = >{ - ...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( - 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( + 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 = {}; + 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( + 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, + ), ); } } diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index b3fce1e..82d254c 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -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)}'; + } +} diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index f1a3a20..289d0aa 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -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, diff --git a/lib/widgets/app_info_table.dart b/lib/widgets/app_info_table.dart index 96a2397..fdcd5c2 100644 --- a/lib/widgets/app_info_table.dart +++ b/lib/widgets/app_info_table.dart @@ -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, diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 844191b..b924171 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -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(), _ => [], }; - 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.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.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( + 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( diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index 986eb40..769f7d5 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -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>({}); diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index d0d62c2..6c2d8b1 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -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'), ), ], diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 29d9a26..108fcdc 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -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 zaps; + /// Optional map of pubkey -> Profile. If not provided, falls back to + /// relationship-loaded profiles (legacy behavior). + final Map? 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 satsPerPubkey = {}; final Map profileByPubkey = {}; 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()