New stacks screen + stack migration

This commit is contained in:
franzap
2026-03-31 15:19:23 -03:00
parent 906c37f634
commit 47ad947c4b
11 changed files with 908 additions and 562 deletions
+21 -10
View File
@@ -22,30 +22,41 @@ const kZapstoreCommunityPubkey =
/// Identifier for storing user saved apps
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
/// Event filter for app stacks - excludes saved apps and stacks with zero App references
/// Event filter for app stacks - must run as schemaFilter so rejected events
/// are never stored in local SQLite.
///
/// Rejects:
/// - Private/encrypted stacks (non-empty content, kind 30267 only)
/// - The user's own saved-apps bookmark stack
/// - Stacks with no public App (32267) references
bool appStackEventFilter(Map<String, dynamic> event) {
// Guard: only apply to AppStack events (kind 30267).
// Other kinds (e.g. App kind 32267) also use content and must not be rejected.
final kind = event['kind'] as int?;
if (kind != null && kind != 30267) return true;
// Private stacks store encrypted app IDs in content — reject before storage
final content = event['content'] as String?;
if (content != null && content.isNotEmpty) return false;
final tags = event['tags'] as List<dynamic>?;
if (tags == null) return false;
// Check for saved apps identifier in 'd' tag
for (final tag in tags) {
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
if (tag is! List || tag.isEmpty) continue;
if (tag[0] == 'd' && tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
return false;
}
}
}
// Check for at least one 'a' tag starting with '32267:' (App kind)
for (final tag in tags) {
if (tag is List && tag.isNotEmpty && tag[0] == 'a') {
if (tag.length > 1 && tag[1] is String && tag[1].startsWith('32267:')) {
if (tag is! List || tag.isEmpty) continue;
if (tag[0] == 'a' && tag.length > 1 &&
tag[1] is String && (tag[1] as String).startsWith('32267:')) {
return true;
}
}
}
// No valid App references found
return false;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/screens/main_scaffold.dart';
import 'package:zapstore/screens/app_detail_screen.dart';
import 'package:zapstore/screens/stacks_screen.dart';
import 'package:zapstore/screens/app_stacks_screen.dart';
import 'package:zapstore/screens/app_stack_screen.dart';
import 'package:zapstore/screens/user_screen.dart';
import 'package:zapstore/screens/search_screen.dart';
@@ -91,7 +91,7 @@ GoRoute _allStacksRoute() {
return GoRoute(
path: 'stacks',
pageBuilder: (context, state) {
return _noTransitionPage(state: state, child: const StacksScreen());
return _noTransitionPage(state: state, child: const AppStacksScreen());
},
);
}
+22 -22
View File
@@ -21,7 +21,6 @@ class AppStackScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Query stack with apps and author relationships
final stackState = ref.watch(
query<AppStack>(
authors: authorPubkey != null ? {authorPubkey!} : null,
@@ -29,51 +28,52 @@ class AppStackScreen extends HookConsumerWidget {
'#d': {stackId},
},
limit: 1,
source: LocalAndRemoteSource(stream: true, relays: 'social'),
subscriptionPrefix: 'app-stack-$stackId',
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-stack-detail-$stackId',
),
);
return switch (stackState) {
StorageError(:final exception) => _ErrorScaffold(
message: exception.toString(),
),
StorageData(:final models) => _AppStackContentWithApps(
stack: models.firstOrNull,
),
StorageLoading() => Scaffold(
if (stackState case StorageError(:final exception)) {
return _ErrorScaffold(message: exception.toString());
}
final stack = stackState.models.firstOrNull;
if (stack == null) {
return Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _AppStackSkeleton(),
),
),
};
);
}
return _AppStackContentWithApps(stack: stack);
}
}
/// Intermediate widget that loads apps with their release relationships
class _AppStackContentWithApps extends HookConsumerWidget {
final AppStack? stack;
final AppStack stack;
const _AppStackContentWithApps({required this.stack});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (stack == null) {
return const _ErrorScaffold(message: 'Stack not found');
}
// Derive identifiers directly from the raw event tags — stable and complete
// regardless of what's been cached locally. The 'a' tag format is
// '32267:pubkey:d-tag'; we extract just the d-tag (identifier) part.
final appIdentifiers = stack!.event
final appIdentifiers = stack.event
.getTagSetValues('a')
.where((id) => id.startsWith('32267:'))
.map((id) => id.split(':').skip(2).join(':'))
.toSet();
if (appIdentifiers.isEmpty) {
return _AppStackContent(stack: stack!, apps: const []);
return _AppStackContent(stack: stack, apps: const []);
}
// Query apps with release and metadata relationships (same pattern as search/user screens)
@@ -92,7 +92,7 @@ class _AppStackContentWithApps extends HookConsumerWidget {
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-stack-apps-${stack!.identifier}',
subscriptionPrefix: 'app-stack-apps-${stack.identifier}',
),
);
@@ -103,7 +103,7 @@ class _AppStackContentWithApps extends HookConsumerWidget {
.whereType<App>()
.toList();
return _AppStackContent(stack: stack!, apps: orderedApps);
return _AppStackContent(stack: stack, apps: orderedApps);
}
}
+532
View File
@@ -0,0 +1,532 @@
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:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/paged_subscription_notifier.dart';
import 'package:zapstore/widgets/app_stack_container.dart';
const int _kPageSize = 20;
// ---------------------------------------------------------------------------
// Notifier & provider
// ---------------------------------------------------------------------------
class StacksNotifier extends PagedSubscriptionNotifier<AppStack> {
StacksNotifier(super.ref, {required this.platform});
final String platform;
ProviderSubscription<StorageState<AppStack>>? _sub;
Map<String, Set<String>> get _tags => {
'#f': {platform},
'#h': {kZapstoreCommunityPubkey},
};
@override
int get pageSize => _kPageSize;
@override
void startSubscription() {
_sub?.close();
_sub = ref.listen(
query<AppStack>(
limit: pageSize,
tags: _tags,
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'app-stacks',
schemaFilter: appStackEventFilter,
),
(_, next) => updateFirstPage(next),
fireImmediately: true,
);
}
@override
Future<({List<AppStack> items, int count})> fetchOlderPage(
DateTime until,
) async {
final storage = ref.read(storageNotifierProvider.notifier);
final items = await storage.query(
RequestFilter<AppStack>(
tags: _tags,
until: until,
limit: pageSize,
).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-stacks-older',
);
return (items: items, count: items.length);
}
@override
String getId(AppStack item) => item.id;
@override
DateTime getCreatedAt(AppStack item) => item.event.createdAt;
@override
void dispose() {
_sub?.close();
super.dispose();
}
}
final stacksProvider =
StateNotifierProvider<StacksNotifier, PagedState<AppStack>>((ref) {
final platform = ref.read(packageManagerProvider.notifier).platform;
return StacksNotifier(ref, platform: platform);
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns true if the stack is public (no encrypted content) and is missing
/// required tags: h (community) or f (platform).
bool stackNeedsMigration(AppStack stack, String platform) {
// Skip private/encrypted stacks
if (stack.content.isNotEmpty) return false;
// Skip the user's saved-apps bookmark stack
if (stack.identifier == kAppBookmarksIdentifier) return false;
final hTags = stack.event.getTagSetValues('h');
final fTags = stack.event.getTagSetValues('f');
final hasH = hTags.contains(kZapstoreCommunityPubkey);
final hasF = fTags.contains(platform);
return !hasH || !hasF;
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
class AppStacksScreen extends HookConsumerWidget {
const AppStacksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrollController = useScrollController();
final platform = ref.read(packageManagerProvider.notifier).platform;
final state = ref.watch(stacksProvider);
final items = state.combined;
// Query signed-in user's stacks that may need migration
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final userStacksState = signedInPubkey != null
? ref.watch(
query<AppStack>(
authors: {signedInPubkey},
where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(
relays: {'social', 'AppCatalog'},
stream: false,
),
subscriptionPrefix: 'app-user-stacks-migration',
),
)
: null;
final unmigrated = userStacksState?.models.toList() ?? [];
final authorPubkeys = items.map((s) => s.event.pubkey).toSet();
final authorsState = authorPubkeys.isNotEmpty
? ref.watch(
query<Profile>(
authors: authorPubkeys,
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-all-stacks-authors',
),
)
: null;
final authorsMap = {
for (final profile in authorsState?.models ?? <Profile>[])
profile.pubkey: profile,
};
final isAuthorsLoading = authorsState is StorageLoading;
final allPreviewIdentifiers = <String>{};
final stackPreviewIds = <String, List<String>>{};
for (final stack in items) {
final ids = getPreviewIdentifiers(stack);
stackPreviewIds[stack.id] = ids;
allPreviewIdentifiers.addAll(ids);
}
final previewAppsState = allPreviewIdentifiers.isNotEmpty
? ref.watch(
query<App>(
tags: {'#d': allPreviewIdentifiers},
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-all-stacks-preview-apps',
),
)
: null;
final appsMap = {
for (final app in previewAppsState?.models ?? <App>[])
app.identifier: app,
};
useEffect(() {
void onScroll() {
final s = ref.read(stacksProvider);
if (s.isLoadingMore || !s.hasMore) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 300) {
ref.read(stacksProvider.notifier).loadMore();
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
final isInitialLoading = state.firstPage is StorageLoading && items.isEmpty;
return Scaffold(
body: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'App Stacks',
style: context.textTheme.headlineMedium,
),
),
),
if (unmigrated.isNotEmpty)
SliverToBoxAdapter(
child: _MigrationBanner(stacks: unmigrated, platform: platform),
),
if (isInitialLoading)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.15,
),
delegate: SliverChildBuilderDelegate(
(context, index) => SkeletonizerConfig(
data: AppColors.getSkeletonizerConfig(
Theme.of(context).brightness,
),
child: const Skeletonizer(child: StackCardSkeleton()),
),
childCount: 10,
),
),
)
else if (items.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.layers_outlined,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No stacks found',
style: context.textTheme.titleMedium,
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.15,
),
delegate: SliverChildBuilderDelegate((context, index) {
final stack = items[index];
final author = authorsMap[stack.event.pubkey];
return StackCard(
stack: stack,
author: author,
isAuthorLoading: isAuthorsLoading && author == null,
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
appsMap: appsMap,
showAuthor: true,
);
}, childCount: items.length),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: state.isLoadingMore
? Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Theme.of(context).colorScheme.primary,
),
),
)
: !state.hasMore && items.isNotEmpty
? Center(
child: Text(
'No more stacks to load',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
)
: const SizedBox.shrink(),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Migration Banner
// ---------------------------------------------------------------------------
class _MigrationBanner extends HookConsumerWidget {
const _MigrationBanner({required this.stacks, required this.platform});
final List<AppStack> stacks;
final String platform;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoading = useState(false);
final progressCount = useState(0);
// Query authors for the pending stacks
final authorPubkeys = stacks.map((s) => s.event.pubkey).toSet();
final authorsState = authorPubkeys.isNotEmpty
? ref.watch(
query<Profile>(
authors: authorPubkeys,
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-migration-stacks-authors',
),
)
: null;
final authorsMap = {
for (final profile in authorsState?.models ?? <Profile>[])
profile.pubkey: profile,
};
final allPreviewIdentifiers = <String>{};
final stackPreviewIds = <String, List<String>>{};
for (final stack in stacks) {
final ids = getPreviewIdentifiers(stack);
stackPreviewIds[stack.id] = ids;
allPreviewIdentifiers.addAll(ids);
}
// Query preview apps
final previewAppsState = allPreviewIdentifiers.isNotEmpty
? ref.watch(
query<App>(
tags: {'#d': allPreviewIdentifiers},
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-migration-stacks-preview-apps',
),
)
: null;
final appsMap = {
for (final app in previewAppsState?.models ?? <App>[])
app.identifier: app,
};
return Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'You have ${stacks.length} stack${stacks.length == 1 ? '' : 's'} that need${stacks.length == 1 ? 's' : ''} updating',
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Update your stacks so they appear in the community feed. '
'Coming soon: ability to delete them.',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.15,
),
itemCount: stacks.length,
itemBuilder: (context, index) {
final stack = stacks[index];
final author = authorsMap[stack.event.pubkey];
return StackCard(
stack: stack,
author: author,
isAuthorLoading:
authorsState is StorageLoading && author == null,
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
appsMap: appsMap,
showAuthor: false,
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isLoading.value
? null
: () => _migrateStacks(
context,
ref,
isLoading,
progressCount,
stacks,
),
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upgrade, size: 18),
label: Text(
isLoading.value
? 'Updating ${progressCount.value}/${stacks.length}...'
: 'Update stacks',
),
),
),
],
),
);
}
Future<void> _migrateStacks(
BuildContext context,
WidgetRef ref,
ValueNotifier<bool> isLoading,
ValueNotifier<int> progressCount,
List<AppStack> pendingStacks,
) async {
isLoading.value = true;
progressCount.value = 0;
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
if (context.mounted) {
context.showError('Sign in required');
}
return;
}
for (final stack in pendingStacks) {
final appIds = stack.event.getTagSetValues('a').toList();
final partialStack = PartialAppStack(
name: stack.name ?? stack.identifier,
identifier: stack.identifier,
platform: platform,
);
partialStack.addCommunityKey(kZapstoreCommunityPubkey);
for (final appId in appIds) {
partialStack.addApp(appId);
}
// Add one second so relays accept the replacement
partialStack.event.createdAt = stack.event.createdAt.add(const Duration(seconds: 1));
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: {'social', 'AppCatalog'}));
progressCount.value++;
}
if (context.mounted) {
context.showInfo(
'Updated ${progressCount.value} stack${progressCount.value == 1 ? '' : 's'}',
);
}
} catch (e) {
if (context.mounted) {
context.showError('Failed to update stacks', technicalDetails: '$e');
}
} finally {
isLoading.value = false;
}
}
}
+73
View File
@@ -24,6 +24,7 @@ import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/widgets/common/note_parser.dart';
import 'package:zapstore/widgets/nwc_widgets.dart';
import 'package:zapstore/widgets/relay_management_card.dart';
import 'package:zapstore/screens/app_stacks_screen.dart';
/// Profile screen for authentication and app settings
class ProfileScreen extends ConsumerWidget {
@@ -201,6 +202,7 @@ class _AuthenticationSection extends ConsumerWidget {
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
_StackMigrationWarning(pubkey: pubkey),
],
);
}
@@ -257,6 +259,77 @@ class _AuthenticationSection extends ConsumerWidget {
}
}
class _StackMigrationWarning extends ConsumerWidget {
const _StackMigrationWarning({required this.pubkey});
final String pubkey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final platform = ref.read(packageManagerProvider.notifier).platform;
final stacksState = ref.watch(
query<AppStack>(
authors: {pubkey},
where: (s) => stackNeedsMigration(s, platform),
source: LocalAndRemoteSource(
relays: {'social', 'AppCatalog'},
stream: false,
),
subscriptionPrefix: 'app-user-stacks-migration-profile',
),
);
final count = stacksState.models.length;
if (count == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 12),
child: InkWell(
onTap: () {
final segments = GoRouterState.of(context).uri.pathSegments;
final first = segments.isNotEmpty ? segments.first : 'search';
context.push('/$first/stacks');
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'$count stack${count == 1 ? '' : 's'} need${count == 1 ? 's' : ''} updating — tap to fix',
style: context.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
fontWeight: FontWeight.w500,
),
),
),
Icon(
Icons.arrow_forward,
size: 14,
color: Theme.of(context).colorScheme.error,
),
],
),
),
),
);
}
}
// Sign in button with Amber installation check
class _SignInButtonWithAmberCheck extends ConsumerWidget {
const _SignInButtonWithAmberCheck({required this.isAmberInstalled});
-272
View File
@@ -1,272 +0,0 @@
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:skeletonizer/skeletonizer.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/app_stack_container.dart';
const int _kPageSize = 6;
class StacksScreen extends HookConsumerWidget {
const StacksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scrollController = useScrollController();
final visibleCount = useState(_kPageSize);
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final platform = ref.read(packageManagerProvider.notifier).platform;
final appStacksState = ref.watch(
query<AppStack>(
limit: 100,
tags: {
'#f': {platform},
},
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
subscriptionPrefix: 'app-stacks',
schemaFilter: appStackEventFilter,
),
);
final contactListState = signedInPubkey != null
? ref.watch(
query<ContactList>(
authors: {signedInPubkey},
limit: 1,
source: const LocalAndRemoteSource(
relays: 'social',
stream: false,
cachedFor: Duration(hours: 1),
),
subscriptionPrefix: 'app-all-stacks-contacts',
),
)
: null;
final followingPubkeys =
contactListState?.models.firstOrNull?.followingPubkeys;
final allStacks = appStacksState.models.toList();
final sortedStacks = _sortStacks(
allStacks,
signedInPubkey: signedInPubkey,
followingPubkeys: followingPubkeys,
);
final displayedStacks = sortedStacks.take(visibleCount.value).toList();
final hasMore = visibleCount.value < sortedStacks.length;
// Batch load author profiles for displayed stacks
final authorPubkeys = displayedStacks.map((s) => s.event.pubkey).toSet();
final authorsState = authorPubkeys.isNotEmpty
? ref.watch(
query<Profile>(
authors: authorPubkeys,
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-all-stacks-authors',
),
)
: null;
final authorsMap = {
for (final profile in authorsState?.models ?? <Profile>[])
profile.pubkey: profile,
};
final isAuthorsLoading = authorsState is StorageLoading;
// Batch load preview apps for displayed stacks
final allPreviewIdentifiers = <String>{};
final stackPreviewIds = <String, List<String>>{};
for (final stack in displayedStacks) {
final ids = getPreviewIdentifiers(stack);
stackPreviewIds[stack.id] = ids;
allPreviewIdentifiers.addAll(ids);
}
final previewAppsState = allPreviewIdentifiers.isNotEmpty
? ref.watch(
query<App>(
tags: {'#d': allPreviewIdentifiers},
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
subscriptionPrefix: 'app-all-stacks-preview-apps',
),
)
: null;
final appsMap = {
for (final app in previewAppsState?.models ?? <App>[])
app.identifier: app,
};
// Infinite scroll: load more when near bottom
useEffect(() {
void onScroll() {
if (!hasMore) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 300) {
visibleCount.value = (visibleCount.value + _kPageSize).clamp(
0,
sortedStacks.length,
);
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController, sortedStacks.length, hasMore]);
return Scaffold(
body: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'App Stacks',
style: context.textTheme.headlineMedium,
),
),
),
if (appStacksState is StorageLoading && sortedStacks.isEmpty)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.15,
),
delegate: SliverChildBuilderDelegate(
(context, index) => SkeletonizerConfig(
data: AppColors.getSkeletonizerConfig(
Theme.of(context).brightness,
),
child: const Skeletonizer(child: StackCardSkeleton()),
),
childCount: _kPageSize,
),
),
)
else if (sortedStacks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.layers_outlined,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No stacks found',
style: context.textTheme.titleMedium,
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 12),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1.15,
),
delegate: SliverChildBuilderDelegate((context, index) {
final stack = displayedStacks[index];
final author = authorsMap[stack.event.pubkey];
return StackCard(
stack: stack,
author: author,
isAuthorLoading: isAuthorsLoading && author == null,
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
appsMap: appsMap,
);
}, childCount: displayedStacks.length),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: hasMore
? const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 3),
),
)
: sortedStacks.isNotEmpty
? Center(
child: Text(
'No more stacks to load',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
)
: const SizedBox.shrink(),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
}
List<AppStack> _sortStacks(
List<AppStack> stacks, {
String? signedInPubkey,
Set<String>? followingPubkeys,
}) {
if (signedInPubkey != null &&
followingPubkeys != null &&
followingPubkeys.isNotEmpty) {
final followed = <AppStack>[];
final others = <AppStack>[];
for (final stack in stacks) {
if (followingPubkeys.contains(stack.pubkey)) {
followed.add(stack);
} else {
others.add(stack);
}
}
followed.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...followed, ...others];
} else {
final franzapStacks = stacks
.where((s) => s.pubkey == kFranzapPubkey)
.toList();
final otherStacks = stacks
.where((s) => s.pubkey != kFranzapPubkey)
.toList();
franzapStacks.sort(
(a, b) => b.event.createdAt.compareTo(a.event.createdAt),
);
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...franzapStacks, ...otherStacks];
}
}
-140
View File
@@ -1,12 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import '../theme.dart';
import '../widgets/common/note_parser.dart';
@@ -141,10 +137,6 @@ class UserScreen extends HookConsumerWidget {
return _StackLinkCard(stack: stack, pubkey: pubkey);
}, childCount: stacks.length),
),
// Republish stacks button - only for signed-in user viewing their own profile
SliverToBoxAdapter(
child: _RepublishStacksButton(stacks: stacks, pubkey: pubkey),
),
],
// Bottom padding
@@ -443,135 +435,3 @@ class _UserBio extends HookWidget {
}
}
class _RepublishStacksButton extends HookConsumerWidget {
const _RepublishStacksButton({
required this.stacks,
required this.pubkey,
});
final List<AppStack> stacks;
final String pubkey;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
// Only show if user is viewing their own profile
if (signedInPubkey == null || signedInPubkey != pubkey) {
return const SizedBox.shrink();
}
final isLoading = useState(false);
final progressCount = useState(0);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: FilledButton.icon(
onPressed: isLoading.value
? null
: () async {
isLoading.value = true;
progressCount.value = 0;
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
if (context.mounted) {
context.showError('Sign in required');
}
return;
}
final platform =
ref.read(packageManagerProvider.notifier).platform;
for (final stack in stacks) {
// Get existing app IDs from the stack
final appIds = stack.event.getTagSetValues('a').toList();
// Check if it's an encrypted stack (has content but no 'a' tags)
final isEncrypted =
stack.content.isNotEmpty && appIds.isEmpty;
if (isEncrypted) {
// For encrypted stacks, decrypt, then re-encrypt with platform
try {
final decryptedContent = await signer.nip44Decrypt(
stack.content,
signedInPubkey,
);
final existingAppIds =
(jsonDecode(decryptedContent) as List)
.cast<String>();
final partialStack = PartialAppStack.withEncryptedApps(
name: stack.name ?? stack.identifier,
identifier: stack.identifier,
apps: existingAppIds,
platform: platform,
);
final signedStack =
await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
} catch (e) {
// Skip stacks that can't be decrypted
continue;
}
} else {
// For public stacks, rebuild with platform
final partialStack = PartialAppStack(
name: stack.name ?? stack.identifier,
identifier: stack.identifier,
platform: platform,
);
partialStack.addCommunityKey(kZapstoreCommunityPubkey);
for (final appId in appIds) {
partialStack.addApp(appId);
}
final signedStack = await partialStack.signWith(signer);
await ref.storage.save({signedStack});
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: {'social', 'AppCatalog'}));
}
progressCount.value++;
}
if (context.mounted) {
context.showInfo(
'Republished ${progressCount.value} stacks with platform tag',
);
}
} catch (e) {
if (context.mounted) {
context.showError(
'Failed to republish stacks',
technicalDetails: '$e',
);
}
} finally {
isLoading.value = false;
}
},
icon: isLoading.value
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.publish, size: 18),
label: Text(
isLoading.value
? 'Republishing ${progressCount.value}/${stacks.length}...'
: 'Republish stacks (${stacks.length})',
),
),
);
}
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
/// State for a chronologically-ordered, paged list backed by a live subscription.
///
/// [firstPage] is the streaming first-page result — local cache is shown
/// immediately and updated as remote events arrive in the background.
/// [olderItems] accumulates items loaded via scroll-triggered [loadMore].
class PagedState<T extends Model<dynamic>> {
final StorageState<T> firstPage;
final List<T> olderItems;
final bool isLoadingMore;
final bool hasMore;
const PagedState({
required this.firstPage,
required this.olderItems,
required this.isLoadingMore,
required this.hasMore,
});
factory PagedState.initial() => PagedState(
firstPage: StorageLoading<T>(const []),
olderItems: const [],
isLoadingMore: false,
hasMore: true,
);
/// All currently loaded items: live first page + accumulated older pages.
List<T> get combined => [...firstPage.models, ...olderItems];
PagedState<T> copyWith({
StorageState<T>? firstPage,
List<T>? olderItems,
bool? isLoadingMore,
bool? hasMore,
}) => PagedState(
firstPage: firstPage ?? this.firstPage,
olderItems: olderItems ?? this.olderItems,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore,
);
}
/// Base notifier for a descending-date list with live subscription + infinite scroll.
///
/// ## Pattern
/// - **First page**: `stream: true` — serves local cache immediately, then merges
/// new relay events as they arrive in the background (local-first).
/// - **Older pages**: one-shot fetches (`stream: false`) with an `until` cursor
/// derived from the oldest loaded item. Never re-subscribed.
///
/// ## Subclass contract
/// 1. Override [startSubscription] — call `ref.listen` with `stream: true` and
/// forward each update to [updateFirstPage].
/// 2. Override [fetchOlderPage] — imperatively fetch one page before [until]
/// with `stream: false`. Return items and raw event count.
/// 3. Override [getId] and [getCreatedAt] for deduplication and cursor.
abstract class PagedSubscriptionNotifier<T extends Model<dynamic>>
extends StateNotifier<PagedState<T>> {
PagedSubscriptionNotifier(this.ref) : super(PagedState.initial()) {
startSubscription();
}
final Ref ref;
/// Items per page. Must match the `limit` used in both queries.
int get pageSize => 5;
/// Set up the first-page subscription with `stream: true`.
/// Must call [updateFirstPage] on every state update.
void startSubscription();
/// Fetch one page of items with creation time strictly before [until].
/// Use `stream: false`. Returns items and raw event count for has-more detection.
Future<({List<T> items, int count})> fetchOlderPage(DateTime until);
/// Stable unique ID for deduplication across pages.
String getId(T item);
/// Creation timestamp used as the pagination cursor.
DateTime getCreatedAt(T item);
/// Called by [startSubscription] with each incoming first-page state.
/// Merges live data and removes stale duplicates from older pages.
@protected
void updateFirstPage(StorageState<T> next) {
if (next is StorageData<T>) {
final liveIds = next.models.map(getId).toSet();
final filteredOlder = state.olderItems
.where((item) => !liveIds.contains(getId(item)))
.toList();
state = state.copyWith(firstPage: next, olderItems: filteredOlder);
} else {
state = state.copyWith(firstPage: next);
}
if (next is StorageError<T>) {
state = state.copyWith(isLoadingMore: false);
}
}
/// Load the next page of older items. Safe to call from scroll listeners.
Future<void> loadMore() async {
final combined = state.combined;
if (state.isLoadingMore || !state.hasMore || combined.isEmpty) return;
final oldest = combined
.map(getCreatedAt)
.reduce((a, b) => a.isBefore(b) ? a : b)
.subtract(const Duration(milliseconds: 1));
state = state.copyWith(isLoadingMore: true);
try {
final result = await fetchOlderPage(oldest);
if (result.items.isNotEmpty) {
final existingIds = combined.map(getId).toSet();
final unique = result.items
.where((item) => !existingIds.contains(getId(item)))
.toList();
state = state.copyWith(
olderItems: [...state.olderItems, ...unique],
isLoadingMore: false,
hasMore: result.count >= pageSize,
);
} else {
state = state.copyWith(isLoadingMore: false, hasMore: false);
}
} catch (e) {
state = state.copyWith(isLoadingMore: false);
rethrow;
}
}
}
+15 -98
View File
@@ -14,7 +14,7 @@ import 'common/profile_avatar.dart';
import 'common/profile_name_widget.dart';
/// Number of stacks to show initially and load per batch
const int _kInitialStacks = 6;
const int _kInitialStacks = 8;
const int _kBatchSize = 6;
/// Get the raw `a` tag values from the stack's event (available immediately)
@@ -35,46 +35,12 @@ List<String> getPreviewIdentifiers(AppStack stack) {
return rawTags.take(3).map(extractStackIdentifier).whereType<String>().toList();
}
/// Sort app stacks: franzap/following first, then by recency
List<AppStack> _sortStacks(
List<AppStack> stacks, {
String? signedInPubkey,
Set<String>? followingPubkeys,
}) {
/// Shuffle stacks with a daily seed for variety
List<AppStack> _shuffleStacks(List<AppStack> stacks, {String? signedInPubkey}) {
final today = DateTime.now();
final dateSeed = today.year * 10000 + today.month * 100 + today.day;
final userSeed = signedInPubkey?.hashCode ?? 0;
final random = Random(dateSeed ^ userSeed);
if (signedInPubkey != null &&
followingPubkeys != null &&
followingPubkeys.isNotEmpty) {
final followed = <AppStack>[];
final others = <AppStack>[];
for (final stack in stacks) {
if (followingPubkeys.contains(stack.pubkey)) {
followed.add(stack);
} else {
others.add(stack);
}
}
followed.shuffle(random);
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...followed, ...others];
} else {
final franzapStacks = stacks
.where((s) => s.pubkey == kFranzapPubkey)
.toList();
final otherStacks = stacks
.where((s) => s.pubkey != kFranzapPubkey)
.toList();
franzapStacks.shuffle(random);
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...franzapStacks, ...otherStacks];
}
return stacks.toList()..shuffle(Random(dateSeed ^ userSeed));
}
/// App Stack Container - horizontally scrollable 2-row grid of stack cards
@@ -95,13 +61,13 @@ class AppStackContainer extends HookConsumerWidget {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
// Get platform from package manager for filtering
final platform = ref.read(packageManagerProvider.notifier).platform;
// Query 30 stacks WITHOUT loading apps (fast, from local storage)
// Only show stacks curated by the zapstore community
final appStacksState = ref.watch(
query<AppStack>(
limit: 30,
authors: {kZapstoreCommunityPubkey},
limit: 20,
tags: {
'#f': {platform},
},
@@ -111,62 +77,19 @@ class AppStackContainer extends HookConsumerWidget {
),
);
// Get contact list for sorting
final contactListState = signedInPubkey != null
? ref.watch(
query<ContactList>(
authors: {signedInPubkey},
limit: 1,
source: const LocalAndRemoteSource(
relays: 'social',
stream: false,
cachedFor: Duration(hours: 1),
),
subscriptionPrefix: 'app-stack-contacts',
),
)
: null;
final followingPubkeys =
contactListState?.models.firstOrNull?.followingPubkeys;
final allStacks = appStacksState.models.toList();
if (allStacks.isEmpty) {
return _buildSkeleton(context);
}
// Sort stacks
final sortedStacks = _sortStacks(
final sortedStacks = _shuffleStacks(
allStacks,
signedInPubkey: signedInPubkey,
followingPubkeys: followingPubkeys,
);
// Only show up to visibleCount
final displayedStacks = sortedStacks.take(visibleCount.value).toList();
// Batch load author profiles for displayed stacks
final authorPubkeys = displayedStacks.map((s) => s.event.pubkey).toSet();
final authorsState = ref.watch(
query<Profile>(
authors: authorPubkeys,
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
cachedFor: Duration(hours: 2),
),
subscriptionPrefix: 'app-stack-authors',
),
);
final authorsMap = {
for (final profile in authorsState.models) profile.pubkey: profile,
};
final isAuthorsLoading = authorsState is StorageLoading;
// Helper to check if a specific author is loading (loading AND not in cache)
bool isAuthorLoading(String pubkey) =>
isAuthorsLoading && authorsMap[pubkey] == null;
// Batch load preview apps for displayed stacks (3 per stack)
final allPreviewIdentifiers = <String>{};
final stackPreviewIds = <String, List<String>>{};
@@ -213,10 +136,8 @@ class AppStackContainer extends HookConsumerWidget {
return () => scrollController.removeListener(onScroll);
}, [scrollController, sortedStacks.length]);
// 2-row horizontal scroll layout
// Add a "See more" card after the last stack if there are more stacks
// 2-row horizontal scroll layout with "See more" card
final showSeeMore = sortedStacks.length > _kInitialStacks;
// Total items = displayed stacks + optional "See more" placeholder
final totalItems = displayedStacks.length + (showSeeMore ? 1 : 0);
final numColumns = (totalItems + 1) ~/ 2;
@@ -240,8 +161,6 @@ class AppStackContainer extends HookConsumerWidget {
context,
col * 2,
displayedStacks,
authorsMap,
isAuthorLoading,
stackPreviewIds,
appsMap,
showSeeMore,
@@ -253,8 +172,6 @@ class AppStackContainer extends HookConsumerWidget {
context,
col * 2 + 1,
displayedStacks,
authorsMap,
isAuthorLoading,
stackPreviewIds,
appsMap,
showSeeMore,
@@ -274,8 +191,6 @@ class AppStackContainer extends HookConsumerWidget {
BuildContext context,
int index,
List<AppStack> displayedStacks,
Map<String, Profile> authorsMap,
bool Function(String) isAuthorLoading,
Map<String, List<String>> stackPreviewIds,
Map<String, App> appsMap,
bool showSeeMore,
@@ -289,8 +204,7 @@ class AppStackContainer extends HookConsumerWidget {
final stack = displayedStacks[index];
return StackCard(
stack: stack,
author: authorsMap[stack.event.pubkey],
isAuthorLoading: isAuthorLoading(stack.event.pubkey),
showAuthor: false,
previewIdentifiers: stackPreviewIds[stack.id] ?? [],
appsMap: appsMap,
);
@@ -415,10 +329,11 @@ class StackCard extends StatelessWidget {
const StackCard({
super.key,
required this.stack,
required this.author,
required this.previewIdentifiers,
required this.appsMap,
this.author,
this.isAuthorLoading = false,
this.showAuthor = true,
});
final AppStack stack;
@@ -426,12 +341,12 @@ class StackCard extends StatelessWidget {
final List<String> previewIdentifiers;
final Map<String, App> appsMap;
final bool isAuthorLoading;
final bool showAuthor;
@override
Widget build(BuildContext context) {
final totalApps = getRawAppTagValues(stack).length;
// Resolve preview apps from the pre-loaded map
final previewApps = previewIdentifiers
.map((id) => appsMap[id])
.whereType<App>()
@@ -476,6 +391,7 @@ class StackCard extends StatelessWidget {
fontSize: (context.textTheme.titleMedium?.fontSize ?? 16) * 0.9,
),
),
if (showAuthor) ...[
const SizedBox(height: 6),
Row(
children: [
@@ -492,6 +408,7 @@ class StackCard extends StatelessWidget {
),
],
),
],
const SizedBox(height: 8),
_AppIconsRow(apps: previewApps, totalApps: totalApps),
],
+89
View File
@@ -100,6 +100,95 @@ onPressed: () async {
}
```
### Paged lists with live subscription (infinite scroll)
Use `PagedSubscriptionNotifier<T>` (`lib/utils/paged_subscription_notifier.dart`)
for any list that is sorted descending by date, supports infinite scroll, and
should reflect new items without user action.
**Rules:**
- The **first page only** uses `stream: true`. It serves local cache immediately
and merges relay events in the background (local-first).
- **All older pages** use `stream: false` with an `until` cursor pointing 1 ms
before the oldest loaded item. Never subscribe to older pages.
- Scroll listeners call `notifier.loadMore()` — the base class handles
deduplication, the `isLoadingMore` guard, and `hasMore` detection.
**How to implement:**
```dart
class MyNotifier extends PagedSubscriptionNotifier<MyModel> {
MyNotifier(super.ref);
ProviderSubscription<StorageState<MyModel>>? _sub;
@override int get pageSize => 10;
@override
void startSubscription() {
_sub?.close();
_sub = ref.listen(
query<MyModel>(
limit: pageSize,
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'app-my-list',
),
(_, next) => updateFirstPage(next),
fireImmediately: true,
);
}
@override
Future<({List<MyModel> items, int count})> fetchOlderPage(DateTime until) async {
final items = await ref.storage.query(
RequestFilter<MyModel>(until: until, limit: pageSize).toRequest(),
source: const LocalAndRemoteSource(stream: false),
subscriptionPrefix: 'app-my-list-older',
);
return (items: items, count: items.length);
}
@override String getId(MyModel item) => item.id;
@override DateTime getCreatedAt(MyModel item) => item.event.createdAt;
@override void dispose() { _sub?.close(); super.dispose(); }
}
final myListProvider = StateNotifierProvider<MyNotifier, PagedState<MyModel>>(
(ref) => MyNotifier(ref),
);
```
**In the widget:**
```dart
// Infinite scroll trigger
useEffect(() {
void onScroll() {
final s = ref.read(myListProvider);
if (s.isLoadingMore || !s.hasMore) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 300) {
ref.read(myListProvider.notifier).loadMore();
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
// Consume state
final state = ref.watch(myListProvider);
final items = state.combined; // first page + all older pages
// Loading / error / content
if (state.firstPage is StorageLoading && items.isEmpty) { /* skeleton */ }
else if (state.firstPage is StorageError) { /* error */ }
else { /* list */ }
```
**Examples:** `LatestReleasesNotifier` (apps via assets), `StacksNotifier` (stacks directly).
### Subscription prefix naming
All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend.
+2 -1
View File
@@ -41,8 +41,9 @@ If any invariant is violated, the implementation is incorrect.
## App Stack (kind 30267) Tag Rules
- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag or `h` tag.
- Encrypted stacks (private collections, e.g. "Saved Apps") MUST NOT include a community `p` tag.
- Only public stacks may carry a community `p` tag to associate them with a community.
- The `h` tag is used for relay grouping/routing and may appear on both public and private stacks.
## Lifecycle Safety