Add mobile community invites (#5641)

## Summary
- add a permission-gated mobile community invite page
- create, copy, and natively share configurable invite links
- invite a validated npub directly with member/admin role selection
- reuse Buzz profile actions, search styling, settings rows, and modal
sheets

## Validation
- `just mobile-check`
- `flutter test` (1,275 tests)
- Pixel and iPhone review builds installed and launched

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-13 17:47:44 +01:00
committed by GitHub
parent a96af89526
commit 8abc2baf0b
20 changed files with 2289 additions and 95 deletions

View File

@@ -9,6 +9,7 @@ import 'features/activity/inbox_local_state_provider.dart';
import 'features/activity/inbox_read_state.dart';
import 'features/channels/unread_badge/unread_badge_provider.dart';
import 'features/home/home_page.dart';
import 'features/invites/invite_create_page.dart';
import 'features/pairing/pairing_page.dart';
import 'features/channels/agent_activity/observer_subscription.dart';
import 'features/channels/deep_link_dispatcher.dart';
@@ -149,6 +150,7 @@ class App extends HookConsumerWidget {
Widget _buildSettingsPage(BuildContext context) => SettingsPage(
profileHeader: const SettingsProfileHeader(),
invitePageBuilder: (_) => const CommunityInvitePage(),
identityRecoveryPageBuilder: (_) =>
const PairingPage(addingCommunity: true, identityRecoveryOnly: true),
);

View File

@@ -0,0 +1,166 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_search_field.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'invite_create_provider.dart';
part 'invite_create_page/invite_link_section.dart';
part 'invite_create_page/person_invite_section.dart';
/// A page for authorized community members to create and send invitations.
class CommunityInvitePage extends ConsumerWidget {
/// Creates the community invitation page.
const CommunityInvitePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
return FrostedScaffold(
backgroundColor: context.colors.surface,
appBar: const FrostedAppBar(title: Text('Invite to community')),
body: roleAsync.when(
loading: () => const Center(
child: BuzzLoadingIndicator(
size: 48,
semanticLabel: 'Checking community permissions',
),
),
error: (_, _) => _InvitePermissionError(
onRetry: () => ref.invalidate(communityMembershipProvider),
),
data: (role) => canManageCommunityInvites(role)
? _CommunityInviteBody(role: role!)
: const _InvitePermissionDenied(),
),
);
}
}
class _CommunityInviteBody extends StatelessWidget {
const _CommunityInviteBody({required this.role});
final CommunityMemberRole role;
@override
Widget build(BuildContext context) {
return ListView(
padding: EdgeInsets.fromLTRB(
0,
frostedAppBarHeight(context) + Grid.xs,
0,
Grid.lg,
),
children: [
const _InviteLinkSection(),
const SizedBox(height: Grid.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(child: Divider(color: context.colors.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Text(
'Or use npub',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
Expanded(child: Divider(color: context.colors.outlineVariant)),
],
),
),
const SizedBox(height: Grid.sm),
_PersonInviteSection(role: role),
],
);
}
}
class _InvitePermissionError extends StatelessWidget {
const _InvitePermissionError({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return _InviteMessage(
icon: LucideIcons.wifiOff,
title: 'Could not check permissions',
body: 'Reconnect to this community and try again.',
action: TextButton(onPressed: onRetry, child: const Text('Retry')),
);
}
}
class _InvitePermissionDenied extends StatelessWidget {
const _InvitePermissionDenied();
@override
Widget build(BuildContext context) {
return const _InviteMessage(
icon: LucideIcons.shieldAlert,
title: 'Invite access required',
body: 'Only community owners and admins can invite people.',
);
}
}
class _InviteMessage extends StatelessWidget {
const _InviteMessage({
required this.icon,
required this.title,
required this.body,
this.action,
});
final IconData icon;
final String title;
final String body;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.gutter),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 36, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xs),
Text(title, style: context.textTheme.titleMedium),
const SizedBox(height: Grid.xxs),
Text(
body,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (action != null) ...[const SizedBox(height: Grid.xs), action!],
],
),
),
);
}
}
String _inviteErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');

View File

@@ -0,0 +1,249 @@
part of '../invite_create_page.dart';
class _InviteLinkSection extends HookConsumerWidget {
const _InviteLinkSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final ttlSeconds = useState(defaultCommunityInviteTtlSeconds);
final maxUses = useState<int?>(null);
final generationId = useRef(0);
final invite = useState<MintedCommunityInvite?>(null);
final isGenerating = useState(true);
final generationError = useState<String?>(null);
Future<void> generate() async {
final requestId = ++generationId.value;
isGenerating.value = true;
invite.value = null;
generationError.value = null;
try {
final created = await ref
.read(communityInviteActionsProvider)
.mintInvite(ttlSeconds: ttlSeconds.value, maxUses: maxUses.value);
if (context.mounted && requestId == generationId.value) {
invite.value = created;
}
} catch (error) {
if (context.mounted && requestId == generationId.value) {
generationError.value = _inviteErrorMessage(error);
}
} finally {
if (context.mounted && requestId == generationId.value) {
isGenerating.value = false;
}
}
}
useEffect(() {
unawaited(generate());
return null;
}, [ttlSeconds.value, maxUses.value]);
final ttlLabel = communityInviteTtlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label;
final maxUsesLabel = communityInviteMaxUseOptions
.firstWhere((option) => option.value == maxUses.value)
.label;
Future<void> share(BuildContext buttonContext) async {
final url = invite.value?.url;
if (url == null) return;
final renderBox = buttonContext.findRenderObject() as RenderBox?;
final origin = renderBox == null
? null
: renderBox.localToGlobal(Offset.zero) & renderBox.size;
try {
await ref.read(shareCommunityInviteProvider)(url, origin);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not share invite link')),
);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(
child: Builder(
builder: (buttonContext) => BuzzActionTile(
key: const Key('community-invite-share-link'),
icon: LucideIcons.share2,
label: 'Share',
isEnabled: invite.value != null,
onTap: () => share(buttonContext),
),
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: BuzzActionTile(
key: const Key('community-invite-copy-link'),
icon: LucideIcons.copy,
label: 'Copy',
isEnabled: invite.value != null,
onTap: () => copyToClipboard(
context,
invite.value!.url,
message: 'Invite link copied',
),
),
),
],
),
),
if (generationError.value case final error?) ...[
const SizedBox(height: Grid.xs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Row(
children: [
Expanded(
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
TextButton(onPressed: generate, child: const Text('Retry')),
],
),
),
],
const SizedBox(height: Grid.xs),
AppListCard(
key: const Key('community-invite-link-settings'),
dividerIndent: Grid.xs,
children: [
AppListRow(
key: const Key('community-invite-expiry-setting'),
title: 'Expires after',
value: ttlLabel,
trailing: const _InviteRowChevron(),
onTap: isGenerating.value
? null
: () => _showInviteOptionSheet<int>(
context: context,
title: 'Expires after',
value: ttlSeconds.value,
options: communityInviteTtlOptions,
onSelected: (value) => ttlSeconds.value = value,
),
),
AppListRow(
key: const Key('community-invite-max-uses-setting'),
title: 'Limit number of uses',
value: maxUsesLabel,
trailing: const _InviteRowChevron(),
onTap: isGenerating.value
? null
: () => _showInviteOptionSheet<int?>(
context: context,
title: 'Limit number of uses',
value: maxUses.value,
options: communityInviteMaxUseOptions,
onSelected: (value) => maxUses.value = value,
),
),
],
),
],
);
}
}
void _showInviteOptionSheet<T>({
required BuildContext context,
required String title,
required T value,
required List<CommunityInviteOption<T>> options,
required ValueChanged<T> onSelected,
}) {
showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => _InviteOptionSheet<T>(
title: title,
value: value,
options: options,
onSelected: onSelected,
),
);
}
class _InviteOptionSheet<T> extends StatelessWidget {
const _InviteOptionSheet({
required this.title,
required this.value,
required this.options,
required this.onSelected,
});
final String title;
final T value;
final List<CommunityInviteOption<T>> options;
final ValueChanged<T> onSelected;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xxs,
),
child: Text(title, style: context.textTheme.titleMedium),
),
for (final option in options)
AppListRow(
key: Key('community-invite-option-${_optionKey(option.label)}'),
title: option.label,
trailing: option.value == value
? Icon(
LucideIcons.check,
size: 18,
color: context.colors.primary,
)
: null,
onTap: () {
onSelected(option.value);
Navigator.of(context).pop();
},
),
const SizedBox(height: Grid.xxs),
],
),
);
}
}
class _InviteRowChevron extends StatelessWidget {
const _InviteRowChevron();
@override
Widget build(BuildContext context) {
return Icon(
LucideIcons.chevronRight,
size: 18,
color: context.colors.onSurfaceVariant,
);
}
}
String _optionKey(String label) => label
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');

View File

@@ -0,0 +1,283 @@
part of '../invite_create_page.dart';
class _PersonInviteSection extends HookConsumerWidget {
const _PersonInviteSection({required this.role});
final CommunityMemberRole role;
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final searchFocusNode = useFocusNode();
final isSearchEditing = useState(false);
final candidatePubkey = useState<String?>(null);
final selectedRole = useState(CommunityMemberRole.member);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
final membership = ref.watch(communityMembershipProvider).asData?.value;
final existingMemberPubkeys = {
...?membership?.pubkeys.map((pubkey) => pubkey.toLowerCase()),
};
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
useEffect(() {
void handleFocusChange() {
isSearchEditing.value = searchFocusNode.hasFocus;
}
searchFocusNode.addListener(handleFocusChange);
return () => searchFocusNode.removeListener(handleFocusChange);
}, [searchFocusNode]);
void selectCandidate(String pubkey) {
final normalized = pubkey.toLowerCase();
if (normalized == currentPubkey) {
candidatePubkey.value = null;
submitError.value = 'You cannot invite yourself.';
return;
}
if (existingMemberPubkeys.contains(normalized)) {
candidatePubkey.value = null;
submitError.value = 'This person is already in the community.';
return;
}
candidatePubkey.value = normalized;
submitError.value = null;
}
void handleInput(String value) {
submitError.value = null;
final pubkey = parseCommunityInvitePubkey(value);
if (pubkey == null) {
candidatePubkey.value = null;
} else {
selectCandidate(pubkey);
}
}
Future<void> submit(CommunityInviteDirectoryUser invitee) async {
if (isSubmitting.value) return;
isSubmitting.value = true;
submitError.value = null;
try {
await ref
.read(communityInviteActionsProvider)
.inviteMembers(
pubkeys: [invitee.pubkey],
role: role == CommunityMemberRole.owner
? selectedRole.value
: CommunityMemberRole.member,
);
if (!context.mounted) return;
candidatePubkey.value = null;
searchController.clear();
ref.invalidate(communityMembershipProvider);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invited to the community')),
);
} catch (error) {
if (context.mounted) {
submitError.value = _inviteErrorMessage(error);
}
} finally {
if (context.mounted) {
isSubmitting.value = false;
}
}
}
final pubkey = candidatePubkey.value;
final profileAsync = pubkey == null
? null
: ref.watch(communityInviteProfileProvider(pubkey));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: SizedBox(
key: const Key('community-invite-recipient-field'),
width: double.infinity,
height: buzzSearchIdleFieldHeight,
child: BuzzSearchField(
fieldKey: const Key('community-invite-search'),
controller: searchController,
focusNode: searchFocusNode,
hintText: 'Search npub',
iconColor: navigationPrimaryForeground(context),
inputColor: navigationPrimaryForeground(context),
placeholderColor: navigationSecondaryForeground(context),
surfaceColor: navigationSearchSurface(context),
isEditing: isSearchEditing.value,
reduceMotion: MediaQuery.disableAnimationsOf(context),
motionDuration: const Duration(milliseconds: 160),
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
textInputAction: TextInputAction.done,
onTap: searchFocusNode.requestFocus,
onChanged: handleInput,
onSubmitted: (value) {
final pubkey = parseCommunityInvitePubkey(value);
if (pubkey == null && value.trim().isNotEmpty) {
submitError.value = 'Paste a valid npub.';
} else if (pubkey != null) {
selectCandidate(pubkey);
}
},
),
),
),
if (pubkey != null && profileAsync != null) ...[
const SizedBox(height: Grid.xs),
_InviteeResolutionCard(
pubkey: pubkey,
profileAsync: profileAsync,
role: role,
selectedRole: selectedRole.value,
isSubmitting: isSubmitting.value,
onRetry: () =>
ref.invalidate(communityInviteProfileProvider(pubkey)),
onRoleSelected: (value) => selectedRole.value = value,
onInvite: submit,
),
],
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
],
],
);
}
}
class _InviteeResolutionCard extends StatelessWidget {
const _InviteeResolutionCard({
required this.pubkey,
required this.profileAsync,
required this.role,
required this.selectedRole,
required this.isSubmitting,
required this.onRetry,
required this.onRoleSelected,
required this.onInvite,
});
final String pubkey;
final AsyncValue<CommunityInviteDirectoryUser?> profileAsync;
final CommunityMemberRole role;
final CommunityMemberRole selectedRole;
final bool isSubmitting;
final VoidCallback onRetry;
final ValueChanged<CommunityMemberRole> onRoleSelected;
final ValueChanged<CommunityInviteDirectoryUser> onInvite;
@override
Widget build(BuildContext context) {
return profileAsync.when(
loading: () => AppListCard(
children: [
AppListRowRaw(
key: Key('community-invite-resolving-$pubkey'),
leading: const SizedBox.square(
dimension: 40,
child: Center(
child: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Resolving profile',
),
),
),
title: const Text('Resolving profile'),
subtitle: Text(shortCommunityInviteNpub(pubkey)),
),
],
),
error: (_, _) => AppListCard(
children: [
AppListRow(
key: Key('community-invite-resolution-error-$pubkey'),
title: 'Could not resolve profile',
subtitle: shortCommunityInviteNpub(pubkey),
trailing: TextButton(
onPressed: onRetry,
child: const Text('Retry'),
),
),
],
),
data: (invitee) {
if (invitee == null) {
return AppListCard(
children: [
AppListRow(
key: Key('community-invite-unresolved-$pubkey'),
title: 'Invalid npub',
subtitle: shortCommunityInviteNpub(pubkey),
),
],
);
}
return AppListCard(
key: const Key('community-invite-person-card'),
dividerIndent: Grid.xs,
children: [
AppListRow(
key: Key('community-invite-resolved-$pubkey'),
title: shortCommunityInviteNpub(invitee.pubkey),
trailing: FilledButton(
key: const Key('community-invite-submit'),
onPressed: isSubmitting ? null : () => onInvite(invitee),
child: isSubmitting
? const BuzzLoadingIndicator(
size: 16,
semanticLabel: 'Inviting person',
)
: const Text('Invite'),
),
),
if (role == CommunityMemberRole.owner)
AppListRow(
key: const Key('community-invite-role'),
title: 'Role',
value: switch (selectedRole) {
CommunityMemberRole.member => 'Member',
CommunityMemberRole.admin => 'Admin',
CommunityMemberRole.owner => 'Owner',
},
trailing: const _InviteRowChevron(),
onTap: isSubmitting
? null
: () => _showInviteOptionSheet<CommunityMemberRole>(
context: context,
title: 'Role',
value: selectedRole,
options: const [
CommunityInviteOption(
label: 'Member',
value: CommunityMemberRole.member,
),
CommunityInviteOption(
label: 'Admin',
value: CommunityMemberRole.admin,
),
],
onSelected: onRoleSelected,
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,424 @@
import 'dart:convert';
import 'dart:ui' show Rect;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:share_plus/share_plus.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
/// The default lifetime of a newly minted community invite link.
const defaultCommunityInviteTtlSeconds = 3 * 24 * 60 * 60;
/// A labeled value shown in a community invite settings sheet.
@immutable
class CommunityInviteOption<T> {
/// Creates an option with its user-facing [label] and submitted [value].
const CommunityInviteOption({required this.label, required this.value});
/// The text presented for this option.
final String label;
/// The value applied when this option is selected.
final T value;
}
/// Supported community invite-link lifetimes.
const communityInviteTtlOptions = [
CommunityInviteOption(label: '1 day', value: 24 * 60 * 60),
CommunityInviteOption(label: '3 days', value: 3 * 24 * 60 * 60),
CommunityInviteOption(label: '7 days', value: 7 * 24 * 60 * 60),
CommunityInviteOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
/// Supported usage limits for a community invite link.
const communityInviteMaxUseOptions = <CommunityInviteOption<int?>>[
CommunityInviteOption(label: 'No limit', value: null),
CommunityInviteOption(label: '1 use', value: 1),
CommunityInviteOption(label: '3 uses', value: 3),
CommunityInviteOption(label: '5 uses', value: 5),
CommunityInviteOption(label: '10 uses', value: 10),
CommunityInviteOption(label: '25 uses', value: 25),
];
/// A community invite link minted by the active relay.
@immutable
class MintedCommunityInvite {
/// Creates a parsed invite-link response.
const MintedCommunityInvite({
required this.code,
required this.expiresAt,
required this.url,
required this.maxUses,
required this.usesRemaining,
});
/// The relay-issued invite code.
final String code;
/// The invite's expiration time in Unix seconds.
final int expiresAt;
/// The complete URL that recipients can open.
final String url;
/// The total permitted uses, or null when unlimited.
final int? maxUses;
/// The number of uses still available, or null when unlimited.
final int? usesRemaining;
/// Parses a relay invite response.
factory MintedCommunityInvite.fromJson(Map<String, dynamic> json) {
return MintedCommunityInvite(
code: json['code'] as String,
expiresAt: json['expires_at'] as int,
url: json['url'] as String,
maxUses: json['max_uses'] as int?,
usesRemaining: json['uses_remaining'] as int?,
);
}
}
/// A relay directory identity that can be invited to a community.
@immutable
class CommunityInviteDirectoryUser {
/// Creates a directory identity for [pubkey] and optional profile metadata.
const CommunityInviteDirectoryUser({
required this.pubkey,
this.displayName,
this.avatarUrl,
this.nip05Handle,
});
/// The identity's lowercase hexadecimal Nostr public key.
final String pubkey;
/// The profile's preferred display name, when published.
final String? displayName;
/// The profile image URL, when published.
final String? avatarUrl;
/// The profile's NIP-05 identifier, when published.
final String? nip05Handle;
/// The best available human-readable identity label.
String get label {
final display = displayName?.trim();
if (display != null && display.isNotEmpty) return display;
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty) return nip05;
return shortCommunityInvitePubkey(pubkey);
}
/// A supporting identity label distinct from [label].
String get secondaryLabel {
final nip05 = nip05Handle?.trim();
if (nip05 != null && nip05.isNotEmpty && nip05 != label) return nip05;
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}…' : pubkey;
}
/// The uppercase first character of [label], or `?` when unavailable.
String get initial => label.isEmpty ? '?' : label[0].toUpperCase();
}
/// Abbreviates a hexadecimal public key for compact display.
String shortCommunityInvitePubkey(String pubkey) =>
pubkey.length > 8 ? '${pubkey.substring(0, 8)}…' : pubkey;
/// Encodes and abbreviates a hexadecimal public key as an npub.
String shortCommunityInviteNpub(String pubkey) {
try {
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pubkey,
);
return npub.length > 20
? '${npub.substring(0, 12)}…${npub.substring(npub.length - 6)}'
: npub;
} catch (_) {
return shortCommunityInvitePubkey(pubkey);
}
}
/// Parses a hexadecimal public key or npub, returning lowercase hex.
String? parseCommunityInvitePubkey(String value) {
final normalized = value.trim();
final hexPattern = RegExp(r'^[0-9a-fA-F]{64}$');
if (hexPattern.hasMatch(normalized)) return normalized.toLowerCase();
try {
final decoded = nostr.Nip19.decode(payload: normalized);
if (decoded.prefix != nostr.Nip19Prefix.npub ||
!hexPattern.hasMatch(decoded.data)) {
return null;
}
return decoded.data.toLowerCase();
} catch (_) {
return null;
}
}
/// Builds the Nostr tags for a kind:9030 community member invitation.
@visibleForTesting
List<List<String>> buildCommunityMemberInviteTags({
required String pubkey,
required CommunityMemberRole role,
}) => [
['p', pubkey.trim().toLowerCase()],
['role', role.name],
];
/// Creates invite links and submits direct community member invitations.
abstract class CommunityInviteActions {
/// Mints a community invite link with the requested limits.
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
});
/// Invites each public key to the active community with [role].
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
});
}
/// Relay-backed implementation of [CommunityInviteActions].
class RelayCommunityInviteActions implements CommunityInviteActions {
/// Creates invite actions bound to the active relay and signing session.
RelayCommunityInviteActions({
required http.Client httpClient,
required String baseUrl,
required String? nsec,
required SignedEventRelay signedEventRelay,
required bool Function() isCommunityActive,
}) : _httpClient = httpClient,
_baseUrl = baseUrl,
_nsec = nsec,
_signedEventRelay = signedEventRelay,
_isCommunityActive = isCommunityActive;
final http.Client _httpClient;
final String _baseUrl;
final String? _nsec;
final SignedEventRelay _signedEventRelay;
final bool Function() _isCommunityActive;
void _ensureCommunityActive() {
if (!_isCommunityActive()) {
throw StateError('Invite cancelled because the active community changed');
}
}
@override
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
}) async {
_ensureCommunityActive();
final url = Uri.parse(_baseUrl).resolve('/api/invites').toString();
final body = <String, Object>{'ttl_secs': ttlSeconds};
if (maxUses != null) body['max_uses'] = maxUses;
final bodyBytes = utf8.encode(jsonEncode(body));
final response = await _httpClient
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: _nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(const Duration(seconds: 15));
_ensureCommunityActive();
final dynamic decoded;
try {
decoded = jsonDecode(response.body);
} on FormatException {
throw Exception('Relay returned an invalid invite response');
}
if (response.statusCode < 200 || response.statusCode >= 300) {
final rawMessage = decoded is Map<String, dynamic>
? decoded['error']
: null;
final message = rawMessage is String ? rawMessage : null;
throw Exception(message ?? 'HTTP ${response.statusCode}');
}
if (decoded is! Map<String, dynamic>) {
throw Exception('Relay returned an invalid invite response');
}
return MintedCommunityInvite.fromJson(decoded);
}
@override
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
}) async {
final normalizedPubkeys = <String>{};
for (final pubkey in pubkeys) {
final parsed = parseCommunityInvitePubkey(pubkey);
if (parsed != null) normalizedPubkeys.add(parsed);
}
for (final pubkey in normalizedPubkeys) {
_ensureCommunityActive();
await _signedEventRelay.submit(
kind: EventKind.relayAdminAddMember,
content: '',
tags: buildCommunityMemberInviteTags(pubkey: pubkey, role: role),
);
}
_ensureCommunityActive();
}
}
/// Supplies the HTTP client used to mint community invite links.
final communityInviteHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Supplies invite operations bound to the current community session.
final communityInviteActionsProvider = Provider<CommunityInviteActions>((ref) {
final config = ref.watch(relayConfigProvider);
final session = ref.read(relaySessionProvider.notifier);
return RelayCommunityInviteActions(
httpClient: ref.watch(communityInviteHttpClientProvider),
baseUrl: config.baseUrl,
nsec: config.nsec,
signedEventRelay: SignedEventRelay(session: session, nsec: config.nsec),
isCommunityActive: () {
final current = ref.read(relayConfigProvider);
return current.baseUrl == config.baseUrl && current.nsec == config.nsec;
},
);
});
List<CommunityInviteDirectoryUser> _directoryUsersFromEvents(
List<NostrEvent> events,
) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) continue;
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
final users = [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
CommunityInviteDirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
];
users.sort((a, b) {
final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase());
return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey);
});
return users;
}
/// Resolves a pasted pubkey to its published profile on the active relay.
///
/// A valid npub already identifies an exact public key, so missing kind:0
/// metadata falls back to a pubkey-only person instead of blocking the invite.
/// Relay failures still surface as errors and malformed inputs return null.
final communityInviteProfileProvider = FutureProvider.autoDispose
.family<CommunityInviteDirectoryUser?, String>((ref, pubkey) async {
final normalized = parseCommunityInvitePubkey(pubkey);
if (normalized == null) return null;
ref.watch(relayConfigProvider);
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
NostrFilters.profile(normalized),
]);
for (final user in _directoryUsersFromEvents(events)) {
if (user.pubkey == normalized) return user;
}
return CommunityInviteDirectoryUser(pubkey: normalized);
});
/// Lists inviteable relay identities, excluding the signed-in user.
final communityInviteDirectoryProvider =
FutureProvider.autoDispose<List<CommunityInviteDirectoryUser>>((ref) async {
ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
final directoryUsers = _directoryUsersFromEvents(
events,
).where((user) => user.pubkey != currentPubkey).toList();
if (directoryUsers.isNotEmpty) return directoryUsers;
// Match the existing mobile person picker: older relays may not support
// a paged kind:0 directory, so fall back to the membership snapshot and
// hydrate whatever profiles are available.
final membership = await ref.watch(communityMembershipProvider.future);
final memberPubkeys = membership.pubkeys
.where((pubkey) => pubkey != currentPubkey)
.toList();
if (memberPubkeys.isEmpty) return const [];
final profileEvents = await ref
.watch(relaySessionProvider.notifier)
.queryRelay([NostrFilters.profilesBatch(memberPubkeys)]);
final profilesByPubkey = {
for (final user in _directoryUsersFromEvents(profileEvents))
user.pubkey: user,
};
final fallbackUsers = [
for (final pubkey in memberPubkeys)
profilesByPubkey[pubkey] ??
CommunityInviteDirectoryUser(pubkey: pubkey),
];
fallbackUsers.sort((a, b) {
final byLabel = a.label.toLowerCase().compareTo(b.label.toLowerCase());
return byLabel != 0 ? byLabel : a.pubkey.compareTo(b.pubkey);
});
return fallbackUsers;
});
/// Searches the active relay for inviteable identities matching a query.
final communityInviteDirectorySearchProvider = FutureProvider.autoDispose
.family<List<CommunityInviteDirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (trimmed.isEmpty) {
return ref.watch(communityInviteDirectoryProvider.future);
}
ref.watch(relayConfigProvider);
final currentPubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
final events = await ref.watch(relaySessionProvider.notifier).queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return _directoryUsersFromEvents(
events,
).where((user) => user.pubkey != currentPubkey).toList();
});
/// Shares [inviteUrl] from an optional platform anchor rectangle.
typedef ShareCommunityInvite =
Future<void> Function(String inviteUrl, Rect? sharePositionOrigin);
/// Supplies the native share-sheet action for community invite links.
final shareCommunityInviteProvider = Provider<ShareCommunityInvite>((ref) {
return (inviteUrl, sharePositionOrigin) async {
await SharePlus.instance.share(
ShareParams(text: inviteUrl, sharePositionOrigin: sharePositionOrigin),
);
};
});

View File

@@ -10,7 +10,7 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/utils/string_utils.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
@@ -216,7 +216,7 @@ class UserProfileSheet extends HookConsumerWidget {
children: [
if (pk != currentPubkey) ...[
Expanded(
child: _ProfileActionTile(
child: BuzzActionTile(
icon: isOpeningDirectMessage.value
? null
: LucideIcons.messageSquare,
@@ -224,13 +224,14 @@ class UserProfileSheet extends HookConsumerWidget {
? 'Opening…'
: 'Message',
isLoading: isOpeningDirectMessage.value,
loadingSemanticLabel: 'Opening direct message',
onTap: openDirectMessage,
),
),
const SizedBox(width: Grid.twelve),
],
Expanded(
child: _ProfileActionTile(
child: BuzzActionTile(
icon: copied.value
? LucideIcons.check
: LucideIcons.key,
@@ -371,59 +372,6 @@ class _ProfilePresenceChip extends StatelessWidget {
}
}
class _ProfileActionTile extends StatelessWidget {
const _ProfileActionTile({
required this.icon,
required this.label,
required this.onTap,
this.isLoading = false,
});
final IconData? icon;
final String label;
final VoidCallback onTap;
final bool isLoading;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: isLoading
? null
: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: 'Opening direct message',
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
);
}
class _ProfileAvatar extends StatelessWidget {
final String? avatarUrl;
final String initial;

View File

@@ -9,6 +9,7 @@ import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_search_field.dart';
import '../../shared/widgets/filter_chip_bar.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
@@ -27,8 +28,6 @@ import '../profile/user_profile.dart';
import 'recent_searches_provider.dart';
import 'search_provider.dart';
part 'search_page/motion_field.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchFieldMinHeight = 36.0;
@@ -318,14 +317,15 @@ class SearchPage extends HookConsumerWidget {
// native input connection before the keyboard is shown.
child: SizedBox(
key: const Key('search-field-container'),
child: _SearchMotionField(
child: BuzzSearchField(
controller: textController,
focusNode: focusNode,
hintText: 'Search messages, channels, and people',
iconColor: searchPrimaryColor,
inputColor: searchPrimaryColor,
placeholderColor: searchPlaceholderColor,
surfaceColor: searchSurfaceColor,
isSearchEditing: isSearchEditing.value,
isEditing: isSearchEditing.value,
reduceMotion: reduceMotion,
motionDuration: _searchFieldMoveDuration,
onTap: activateSearch,

View File

@@ -10,6 +10,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
@@ -21,16 +22,25 @@ import 'accent_picker_page.dart';
import 'theme_picker_page.dart';
part 'settings_page/appearance_section.dart';
part 'settings_page/community_section.dart';
part 'settings_page/connection_section.dart';
class SettingsPage extends HookConsumerWidget {
/// Creates the settings page.
const SettingsPage({
super.key,
required this.profileHeader,
required this.invitePageBuilder,
required this.identityRecoveryPageBuilder,
});
/// Header widget displayed at the top of settings.
final Widget profileHeader;
/// Builds the community-invite page pushed from the invite settings row.
final WidgetBuilder invitePageBuilder;
/// Builds the identity-recovery page pushed from the recovery settings row.
final WidgetBuilder identityRecoveryPageBuilder;
@override
@@ -71,6 +81,7 @@ class SettingsPage extends HookConsumerWidget {
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
children: [
profileHeader,
_CommunitySection(invitePageBuilder: invitePageBuilder),
const _AppearanceSection(),
_ConnectionSection(
identityRecoveryPageBuilder: identityRecoveryPageBuilder,

View File

@@ -0,0 +1,29 @@
part of '../settings_page.dart';
class _CommunitySection extends ConsumerWidget {
const _CommunitySection({required this.invitePageBuilder});
final WidgetBuilder invitePageBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
final roleAsync = ref.watch(currentCommunityRoleProvider);
if (!roleAsync.hasError && !canManageCommunityInvites(roleAsync.value)) {
return const SizedBox.shrink();
}
return AppListCard(
label: 'Community',
children: [
AppListRow(
icon: LucideIcons.userPlus,
title: 'Invite to community',
trailing: const _RowChevron(),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: invitePageBuilder)),
),
],
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
/// Role levels assigned by a community membership snapshot.
enum CommunityMemberRole {
/// Full community ownership permissions.
owner,
/// Community administration permissions.
admin,
/// Standard community membership permissions.
member,
}
/// A member and their role in the active community.
@immutable
class CommunityMember {
/// Creates a community member.
const CommunityMember({required this.pubkey, required this.role});
/// The member's normalized hexadecimal public key.
final String pubkey;
/// The member's role in the community.
final CommunityMemberRole role;
}
/// The latest membership list published by the active community.
@immutable
class CommunityMembershipSnapshot {
/// Creates a community membership snapshot.
const CommunityMembershipSnapshot({
required this.snapshotFound,
required this.members,
});
/// Whether the relay returned a membership snapshot event.
final bool snapshotFound;
/// The valid members parsed from the latest snapshot.
final List<CommunityMember> members;
/// Returns the role assigned to [pubkey], or `null` when it is not a member.
CommunityMemberRole? roleFor(String? pubkey) {
final normalized = pubkey?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return null;
for (final member in members) {
if (member.pubkey == normalized) return member.role;
}
return null;
}
/// The normalized public keys in this membership snapshot.
Set<String> get pubkeys => {for (final member in members) member.pubkey};
}
CommunityMemberRole _communityMemberRole(String? value) => switch (value) {
'owner' => CommunityMemberRole.owner,
'admin' => CommunityMemberRole.admin,
_ => CommunityMemberRole.member,
};
/// Parses the current kind:13534 membership snapshot.
///
/// Buzz emits `["member", pubkey, role]`. Older NIP-29-compatible relays may
/// use `["p", pubkey, relay, role]`, so mobile accepts both forms just like
/// desktop does.
@visibleForTesting
CommunityMembershipSnapshot communityMembershipFromEvents(
List<NostrEvent> events,
) {
if (events.isEmpty) {
return const CommunityMembershipSnapshot(snapshotFound: false, members: []);
}
final event = events.reduce(
(latest, candidate) =>
candidate.createdAt > latest.createdAt ? candidate : latest,
);
final members = <CommunityMember>[];
final seen = <String>{};
final pubkeyPattern = RegExp(r'^[0-9a-f]{64}$');
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) continue;
final pubkey = tag[1].trim().toLowerCase();
if (!pubkeyPattern.hasMatch(pubkey) || !seen.add(pubkey)) continue;
final role = tag[0] == 'member'
? (tag.length >= 3 ? tag[2] : null)
: (tag.length >= 4 ? tag[3] : null);
members.add(
CommunityMember(pubkey: pubkey, role: _communityMemberRole(role)),
);
}
return CommunityMembershipSnapshot(snapshotFound: true, members: members);
}
/// Relay membership for the active community.
///
/// The HTTP query resolves from the first response rather than waiting for a
/// WebSocket EOSE frame, and watching [relayConfigProvider] makes the result
/// community-scoped.
final communityMembershipProvider =
FutureProvider.autoDispose<CommunityMembershipSnapshot>((ref) async {
ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider.notifier);
final events = await session.queryRelay([NostrFilters.relayMembers()]);
return communityMembershipFromEvents(events);
});
/// The active user's role in the current community.
final currentCommunityRoleProvider = Provider<AsyncValue<CommunityMemberRole?>>(
(ref) {
final pubkey = ref.watch(myPubkeyProvider);
return ref
.watch(communityMembershipProvider)
.whenData((snapshot) => snapshot.roleFor(pubkey));
},
);
/// Whether [role] is allowed to create community invitations.
bool canManageCommunityInvites(CommunityMemberRole? role) =>
role == CommunityMemberRole.owner || role == CommunityMemberRole.admin;

View File

@@ -203,7 +203,7 @@ abstract final class NostrFilters {
/// Relay membership list (kind:13534).
static NostrFilter relayMembers() =>
const NostrFilter(kinds: [13534], limit: 1);
const NostrFilter(kinds: [EventKind.relayMembership], limit: 1);
/// Agent profiles (kind:10100).
static NostrFilter agentProfiles() =>

View File

@@ -10,6 +10,12 @@ abstract final class EventKind {
static const contactList = 3;
static const deletion = 5;
static const reaction = 7;
/// Kind:9030 event requesting that the relay add a community member.
static const relayAdminAddMember = 9030;
/// Kind:13534 event containing the current relay-community membership.
static const relayMembership = 13534;
static const streamMessage = 9;
static const nip29DeleteEvent = 9005;
static const presenceUpdate = 20001;

View File

@@ -7,11 +7,21 @@ import 'app_list_inset.dart';
/// it. Rows inside are hairline-separated and inset to the card rather than the
/// page, via [AppListInset].
class AppListCard extends StatelessWidget {
const AppListCard({super.key, this.label, required this.children});
const AppListCard({
super.key,
this.label,
this.dividerIndent,
required this.children,
});
/// Rendered above the card in sentence case, as written — no uppercasing.
final String? label;
/// Separator inset from the card edge. Defaults to the standard row label
/// column, clearing a leading icon. Icon-free cards can pass [_inset] so
/// separators align with their row content on both sides.
final double? dividerIndent;
final List<Widget> children;
static const _inset = Grid.xs;
@@ -29,7 +39,7 @@ class AppListCard extends StatelessWidget {
Divider(
height: 1,
thickness: 1,
indent: _dividerIndent,
indent: dividerIndent ?? _dividerIndent,
endIndent: _inset,
// The scheme's own border tokens are derived from the page surface,
// which lands them within a few levels of the card fill — invisible.

View File

@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/theme.dart';
import 'buzz_loading_indicator.dart';
/// Equal-width icon action used by profile and profile-adjacent surfaces.
class BuzzActionTile extends StatelessWidget {
/// Creates an action tile with an optional loading state.
const BuzzActionTile({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.isEnabled = true,
this.isLoading = false,
this.loadingSemanticLabel,
});
/// Icon shown when the tile is not loading.
final IconData? icon;
/// Label shown below the icon.
final String label;
/// Called when the tile is tapped.
final VoidCallback onTap;
/// Whether the tile accepts taps.
final bool isEnabled;
/// Whether to show a loading indicator instead of [icon].
final bool isLoading;
/// Accessibility label for the loading indicator.
final String? loadingSemanticLabel;
@override
Widget build(BuildContext context) {
final canTap = isEnabled && !isLoading;
return GestureDetector(
onTap: canTap
? () {
unawaited(HapticFeedback.lightImpact());
onTap();
}
: null,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: isEnabled ? 1 : 0.5,
child: Container(
width: double.infinity,
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isLoading)
BuzzLoadingIndicator(
size: 22,
color: context.colors.onSurface,
semanticLabel: loadingSemanticLabel ?? label,
)
else
Icon(icon, size: 22, color: context.colors.onSurface),
const SizedBox(height: Grid.xxs),
Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
),
),
);
}
}

View File

@@ -1,43 +1,100 @@
part of '../search_page.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
const _searchIdleIconSize = 26.0;
const _searchCompactIconSize = 18.0;
const _searchFieldHint = 'Search messages, channels, and people';
const _searchIdleIconInset = Grid.xxs;
const _searchIdleTextInset =
import '../theme/theme.dart';
/// Height of an idle [BuzzSearchField].
const double buzzSearchIdleFieldHeight = 45;
/// Font size used for idle [BuzzSearchField] text.
const double buzzSearchIdleTextSize = 15;
const double _searchIdleIconSize = 26;
const double _searchCompactIconSize = 18;
const double _searchIdleIconInset = Grid.xxs;
const double _searchIdleTextInset =
_searchIdleIconInset + _searchIdleIconSize + Grid.xxs;
const _searchCompactTextInset =
const double _searchCompactTextInset =
_searchIdleIconInset + _searchCompactIconSize + Grid.xxs;
class _SearchMotionField extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Color iconColor;
final Color inputColor;
final Color placeholderColor;
final Color surfaceColor;
final bool isSearchEditing;
final bool reduceMotion;
final Duration motionDuration;
final VoidCallback onTap;
final ValueChanged<String> onChanged;
final ValueChanged<String> onSubmitted;
const _SearchMotionField({
/// Buzz's global-search text field treatment, shared by search-like inputs.
class BuzzSearchField extends StatelessWidget {
/// Creates a search field with Buzz's shared styling.
const BuzzSearchField({
required this.controller,
required this.focusNode,
required this.hintText,
required this.iconColor,
required this.inputColor,
required this.placeholderColor,
required this.surfaceColor,
required this.isSearchEditing,
required this.isEditing,
required this.reduceMotion,
required this.motionDuration,
required this.onTap,
required this.onChanged,
required this.onSubmitted,
this.fieldKey = const Key('search-field'),
this.autocorrect = true,
this.enableSuggestions = true,
this.enabled = true,
this.textInputAction = TextInputAction.search,
super.key,
});
/// Controller that owns the field's editable text.
final TextEditingController controller;
/// Node that controls the field's focus.
final FocusNode focusNode;
/// Placeholder shown while the field is idle and empty.
final String hintText;
/// Color applied to the search icon.
final Color iconColor;
/// Color applied to entered text.
final Color inputColor;
/// Color applied to [hintText].
final Color placeholderColor;
/// Background color of the field.
final Color surfaceColor;
/// Whether the field is in its compact editing state.
final bool isEditing;
/// Whether to suppress field animations for reduced-motion users.
final bool reduceMotion;
/// Duration used by the field's editing-state animation.
final Duration motionDuration;
/// Called when the field is tapped.
final VoidCallback onTap;
/// Called whenever the field's text changes.
final ValueChanged<String> onChanged;
/// Called when the user submits the field.
final ValueChanged<String> onSubmitted;
/// Key assigned to the underlying text field.
final Key fieldKey;
/// Whether the text field should autocorrect user input.
final bool autocorrect;
/// Whether the platform should offer text suggestions.
final bool enableSuggestions;
/// Whether the text field accepts user input.
final bool enabled;
/// Action displayed on the keyboard's submit key.
final TextInputAction textInputAction;
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
@@ -52,33 +109,36 @@ class _SearchMotionField extends StatelessWidget {
child: SizedBox(
width: double.infinity,
child: TextField(
key: const Key('search-field'),
key: fieldKey,
controller: controller,
focusNode: focusNode,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
enabled: enabled,
decoration: InputDecoration(
hintText: isSearchEditing ? null : _searchFieldHint,
hintText: isEditing ? null : hintText,
hintStyle: searchInputTextStyle.copyWith(
color: placeholderColor,
fontSize: _searchIdleTextSize,
height: 20 / _searchIdleTextSize,
fontSize: buzzSearchIdleTextSize,
height: 20 / buzzSearchIdleTextSize,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(
left: isSearchEditing
left: isEditing
? _searchCompactTextInset
: _searchIdleTextInset,
right: Grid.xxs,
top: isSearchEditing ? _searchFieldVerticalPadding : 0,
bottom: isSearchEditing ? _searchFieldVerticalPadding : 0,
top: isEditing ? Grid.xxs : 0,
bottom: isEditing ? Grid.xxs : 0,
),
),
style: searchInputTextStyle.copyWith(color: inputColor),
textAlignVertical: TextAlignVertical.center,
textAlign: TextAlign.start,
textInputAction: TextInputAction.search,
textInputAction: textInputAction,
onTap: onTap,
onChanged: onChanged,
onSubmitted: onSubmitted,
@@ -94,7 +154,7 @@ class _SearchMotionField extends StatelessWidget {
child: AnimatedScale(
duration: reduceMotion ? Duration.zero : motionDuration,
curve: Curves.easeInOutCubic,
scale: isSearchEditing
scale: isEditing
? _searchCompactIconSize / _searchIdleIconSize
: 1,
child: Icon(

View File

@@ -0,0 +1,448 @@
import 'dart:async';
import 'package:buzz/features/invites/invite_create_page.dart';
import 'package:buzz/features/invites/invite_create_provider.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:nostr/nostr.dart' as nostr;
void main() {
const owner =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const alice =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
testWidgets('owner can invite a pasted npub and share generated link', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
String? sharedUrl;
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.owner),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [
CommunityMember(pubkey: owner, role: CommunityMemberRole.owner),
],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteDirectoryProvider.overrideWith(
(ref) async => const [
CommunityInviteDirectoryUser(
pubkey: alice,
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
],
),
communityInviteDirectorySearchProvider.overrideWith(
(ref, query) async => const [],
),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) async => CommunityInviteDirectoryUser(
pubkey: pubkey,
displayName: 'Resolved person',
nip05Handle: 'person@example.com',
),
),
communityInviteActionsProvider.overrideWithValue(actions),
shareCommunityInviteProvider.overrideWithValue((url, origin) async {
sharedUrl = url;
}),
],
child: MaterialApp(
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
expect(actions.mintRequests, [(defaultCommunityInviteTtlSeconds, null)]);
expect(
find.text('Add someone directly or share a link they can use to join.'),
findsNothing,
);
expect(find.text('Invite people'), findsNothing);
expect(find.text('Search by name or paste an npub.'), findsNothing);
expect(find.text('Alice'), findsNothing);
expect(
find.byKey(const Key('community-invite-person-$alice')),
findsNothing,
);
expect(find.text('https://relay.example.com/invite/1'), findsNothing);
expect(find.text('Invite link ready'), findsNothing);
expect(find.text('Invite link'), findsNothing);
expect(find.text('Creating invite link…'), findsNothing);
expect(find.text('Or use npub'), findsOneWidget);
expect(find.text('Search npub'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(find.text('Share'), findsOneWidget);
expect(find.text('Messages'), findsNothing);
expect(find.text('Mail'), findsNothing);
expect(find.text('WhatsApp'), findsNothing);
expect(find.text('Gmail'), findsNothing);
expect(find.text('Telegram'), findsNothing);
expect(find.text('X'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('community-invite-search'))).width,
greaterThan(350),
);
final shareSize = tester.getSize(
find.byKey(const Key('community-invite-share-link')),
);
final copySize = tester.getSize(
find.byKey(const Key('community-invite-copy-link')),
);
expect(shareSize, copySize);
expect(shareSize.height, 68 + (Grid.xxs * 2));
expect(
tester
.getTopLeft(find.byKey(const Key('community-invite-share-link')))
.dx,
lessThan(
tester
.getTopLeft(find.byKey(const Key('community-invite-copy-link')))
.dx,
),
);
expect(tester.widget<Icon>(find.byIcon(LucideIcons.share2)).size, 22);
expect(tester.widget<Icon>(find.byIcon(LucideIcons.copy)).size, 22);
final settingsDivider = find.descendant(
of: find.byKey(const Key('community-invite-link-settings')),
matching: find.byType(Divider),
);
final divider = tester.widget<Divider>(settingsDivider);
expect(divider.indent, Grid.xs);
expect(divider.endIndent, Grid.xs);
expect(
tester.getRect(settingsDivider).left + divider.indent!,
closeTo(tester.getRect(find.text('Expires after')).left, 0.5),
);
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('community-invite-resolved-$pastedPubkey')),
findsOneWidget,
);
expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget);
expect(find.text('Resolved person'), findsNothing);
expect(find.text('person@example.com'), findsNothing);
expect(
find.byKey(const Key('community-invite-person-$pastedPubkey')),
findsNothing,
);
expect(find.text('Role'), findsOneWidget);
expect(find.byIcon(LucideIcons.shield), findsNothing);
final personDivider = find.descendant(
of: find.byKey(const Key('community-invite-person-card')),
matching: find.byType(Divider),
);
expect(personDivider, findsOneWidget);
final personDividerWidget = tester.widget<Divider>(personDivider);
expect(personDividerWidget.indent, Grid.xs);
expect(personDividerWidget.endIndent, Grid.xs);
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pumpAndSettle();
expect(actions.memberInvites, hasLength(1));
expect(actions.memberInvites.single.$1, [pastedPubkey]);
expect(actions.memberInvites.single.$2, CommunityMemberRole.member);
await tester.ensureVisible(
find.byKey(const Key('community-invite-share-link')),
);
await tester.tap(find.byKey(const Key('community-invite-share-link')));
await tester.pumpAndSettle();
expect(sharedUrl, 'https://relay.example.com/invite/1');
await tester.ensureVisible(
find.byKey(const Key('community-invite-copy-link')),
);
await tester.tap(find.byKey(const Key('community-invite-copy-link')));
await tester.pumpAndSettle();
expect(
hapticCalls.map((call) => call.arguments),
everyElement('HapticFeedbackType.lightImpact'),
);
expect(hapticCalls, hasLength(2));
});
testWidgets('pasted npub remains inviteable without profile metadata', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
final profileResolution = Completer<CommunityInviteDirectoryUser?>();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) => profileResolution.future,
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pump();
expect(
find.byKey(const Key('community-invite-resolving-$pastedPubkey')),
findsOneWidget,
);
expect(find.byKey(const Key('community-invite-submit')), findsNothing);
expect(actions.memberInvites, isEmpty);
profileResolution.complete(
const CommunityInviteDirectoryUser(pubkey: pastedPubkey),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('community-invite-resolved-$pastedPubkey')),
findsOneWidget,
);
expect(find.text('Profile not found'), findsNothing);
expect(find.text(shortCommunityInviteNpub(pastedPubkey)), findsOneWidget);
expect(find.byKey(const Key('community-invite-submit')), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pumpAndSettle();
expect(actions.memberInvites, hasLength(1));
expect(actions.memberInvites.single.$1, [pastedPubkey]);
expect(actions.memberInvites.single.$2, CommunityMemberRole.member);
});
testWidgets('invite completion does not update state after page disposal', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions()
..memberInviteCompleter = Completer<void>();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
myPubkeyProvider.overrideWithValue(owner),
communityInviteProfileProvider.overrideWith(
(ref, pubkey) async => CommunityInviteDirectoryUser(pubkey: pubkey),
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
const pastedPubkey =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
final npub = nostr.Nip19.encode(
prefix: nostr.Nip19Prefix.npub,
data: pastedPubkey,
);
await tester.enterText(
find.byKey(const Key('community-invite-search')),
npub,
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('community-invite-submit')));
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
actions.memberInviteCompleter!.complete();
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('link settings remint with desktop expiry and use options', (
tester,
) async {
tester.view.physicalSize = const Size(430, 1100);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final actions = _FakeInviteActions();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
communityMembershipProvider.overrideWith(
(ref) async => const CommunityMembershipSnapshot(
snapshotFound: true,
members: [],
),
),
communityInviteDirectoryProvider.overrideWith(
(ref) async => const [],
),
communityInviteActionsProvider.overrideWithValue(actions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('community-invite-max-uses-setting')),
);
await tester.pumpAndSettle();
expect(find.text('Limit number of uses'), findsNWidgets(2));
expect(find.byIcon(LucideIcons.check), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-option-5-uses')));
await tester.pumpAndSettle();
expect(actions.mintRequests, [
(defaultCommunityInviteTtlSeconds, null),
(defaultCommunityInviteTtlSeconds, 5),
]);
await tester.tap(find.byKey(const Key('community-invite-expiry-setting')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsNWidgets(2));
expect(find.byIcon(LucideIcons.check), findsOneWidget);
await tester.tap(find.byKey(const Key('community-invite-option-7-days')));
await tester.pumpAndSettle();
expect(actions.mintRequests.last, (7 * 24 * 60 * 60, 5));
});
testWidgets('plain members cannot open invite tools', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.member),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const CommunityInvitePage(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite access required'), findsOneWidget);
expect(find.byKey(const Key('community-invite-search')), findsNothing);
});
}
class _FakeInviteActions implements CommunityInviteActions {
final List<(int, int?)> mintRequests = [];
final List<(List<String>, CommunityMemberRole)> memberInvites = [];
Completer<void>? memberInviteCompleter;
@override
Future<void> inviteMembers({
required Iterable<String> pubkeys,
required CommunityMemberRole role,
}) async {
memberInvites.add((pubkeys.toList(), role));
await memberInviteCompleter?.future;
}
@override
Future<MintedCommunityInvite> mintInvite({
required int ttlSeconds,
required int? maxUses,
}) async {
mintRequests.add((ttlSeconds, maxUses));
return MintedCommunityInvite(
code: '${mintRequests.length}',
expiresAt: 12345,
url: 'https://relay.example.com/invite/${mintRequests.length}',
maxUses: maxUses,
usesRemaining: maxUses,
);
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:convert';
import 'package:buzz/features/invites/invite_create_provider.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
void main() {
test('accepts hex and npub inputs but rejects other NIP-19 values', () {
final keys = nostr.Keys.generate();
expect(parseCommunityInvitePubkey(keys.public), keys.public);
expect(parseCommunityInvitePubkey(keys.public.toUpperCase()), keys.public);
expect(parseCommunityInvitePubkey(keys.npub), keys.public);
expect(parseCommunityInvitePubkey(keys.nsec), isNull);
expect(parseCommunityInvitePubkey('not-a-pubkey'), isNull);
});
test('builds the desktop-compatible kind:9030 role tags', () {
const pubkey =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
expect(
buildCommunityMemberInviteTags(
pubkey: pubkey.toUpperCase(),
role: CommunityMemberRole.admin,
),
[
['p', pubkey],
['role', 'admin'],
],
);
});
test(
'valid npub falls back to its pubkey when profile metadata is absent',
() async {
final keys = nostr.Keys.generate();
final session = _ProfileRelaySession(const []);
final container = ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final invitee = await container.read(
communityInviteProfileProvider(keys.npub).future,
);
expect(invitee?.pubkey, keys.public);
expect(invitee?.displayName, isNull);
expect(session.queryCount, 1);
},
);
test('mints an unlimited invite with exact NIP-98 request shape', () async {
final keys = nostr.Keys.generate();
late http.Request captured;
final client = http_testing.MockClient((request) async {
captured = request;
return http.Response(
jsonEncode({
'code': 'invite-code',
'expires_at': 12345,
'url': 'https://relay.example.com/invite/invite-code',
'max_uses': null,
'uses_remaining': null,
}),
200,
);
});
final service = RelayCommunityInviteActions(
httpClient: client,
baseUrl: 'https://relay.example.com',
nsec: keys.nsec,
signedEventRelay: SignedEventRelay(
session: _UnusedRelaySession(),
nsec: keys.nsec,
),
isCommunityActive: () => true,
);
final invite = await service.mintInvite(
ttlSeconds: defaultCommunityInviteTtlSeconds,
maxUses: null,
);
expect(captured.method, 'POST');
expect(captured.url, Uri.parse('https://relay.example.com/api/invites'));
expect(captured.headers['content-type'], 'application/json');
expect(captured.headers['authorization'], startsWith('Nostr '));
expect(jsonDecode(captured.body), {
'ttl_secs': defaultCommunityInviteTtlSeconds,
});
expect(invite.code, 'invite-code');
expect(invite.maxUses, isNull);
expect(invite.usesRemaining, isNull);
});
test('includes a selected maximum-use limit', () async {
final keys = nostr.Keys.generate();
late http.Request captured;
final client = http_testing.MockClient((request) async {
captured = request;
return http.Response(
jsonEncode({
'code': 'limited',
'expires_at': 12345,
'url': 'https://relay.example.com/invite/limited',
'max_uses': 5,
'uses_remaining': 5,
}),
200,
);
});
final service = RelayCommunityInviteActions(
httpClient: client,
baseUrl: 'https://relay.example.com',
nsec: keys.nsec,
signedEventRelay: SignedEventRelay(
session: _UnusedRelaySession(),
nsec: keys.nsec,
),
isCommunityActive: () => true,
);
await service.mintInvite(ttlSeconds: 86400, maxUses: 5);
expect(jsonDecode(captured.body), {'ttl_secs': 86400, 'max_uses': 5});
});
}
class _UnusedRelaySession extends RelaySessionNotifier {}
class _TestRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
const RelayConfig(baseUrl: 'https://relay.example.com');
}
class _ProfileRelaySession extends RelaySessionNotifier {
_ProfileRelaySession(this.events);
final List<NostrEvent> events;
int queryCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
queryCount++;
return events;
}
}

View File

@@ -0,0 +1,109 @@
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets('shows community invite navigation to owners and admins', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.admin),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget);
expect(
find.text('Add people directly or share an invite link'),
findsNothing,
);
await tester.tap(find.text('Invite to community'));
await tester.pumpAndSettle();
expect(find.text('Invite destination'), findsOneWidget);
});
testWidgets('keeps invite navigation available when role lookup fails', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
AsyncError<CommunityMemberRole?>(
Exception('membership query failed'),
StackTrace.empty,
),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsOneWidget);
await tester.tap(find.text('Invite to community'));
await tester.pumpAndSettle();
expect(find.text('Invite destination'), findsOneWidget);
});
testWidgets('hides community invite navigation from plain members', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
currentCommunityRoleProvider.overrideWithValue(
const AsyncData<CommunityMemberRole?>(CommunityMemberRole.member),
),
],
child: MaterialApp(
theme: AppTheme.light(),
home: SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const Text('Invite destination'),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite to community'), findsNothing);
});
}

View File

@@ -174,6 +174,7 @@ void main() {
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4},
@@ -189,6 +190,7 @@ void main() {
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
prefs: {

View File

@@ -0,0 +1,72 @@
import 'package:buzz/shared/community/community_membership_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const owner =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const admin =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const member =
'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc';
test('parses Buzz and legacy membership tags with roles', () {
final snapshot = communityMembershipFromEvents([
_event(
createdAt: 2,
tags: [
['member', owner, 'owner'],
['member', admin.toUpperCase(), 'admin'],
['p', member, 'wss://relay.example.com', 'member'],
['member', 'not-a-pubkey', 'owner'],
['member', owner, 'member'],
],
),
]);
expect(snapshot.snapshotFound, isTrue);
expect(snapshot.members, hasLength(3));
expect(snapshot.roleFor(owner), CommunityMemberRole.owner);
expect(snapshot.roleFor(admin), CommunityMemberRole.admin);
expect(snapshot.roleFor(member), CommunityMemberRole.member);
expect(snapshot.pubkeys, {owner, admin, member});
});
test('uses the latest membership snapshot', () {
final snapshot = communityMembershipFromEvents([
_event(
createdAt: 1,
tags: [
['member', owner, 'owner'],
],
),
_event(
createdAt: 2,
tags: [
['member', owner, 'admin'],
],
),
]);
expect(snapshot.roleFor(owner), CommunityMemberRole.admin);
});
test('fails closed when no snapshot is available', () {
final snapshot = communityMembershipFromEvents(const []);
expect(snapshot.snapshotFound, isFalse);
expect(snapshot.members, isEmpty);
expect(canManageCommunityInvites(snapshot.roleFor(owner)), isFalse);
});
}
NostrEvent _event({required int createdAt, required List<List<String>> tags}) =>
NostrEvent(
id: '$createdAt',
pubkey: 'relay',
createdAt: createdAt,
kind: EventKind.relayMembership,
tags: tags,
content: '',
sig: '',
);