mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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),
|
||||
);
|
||||
|
||||
166
mobile/lib/features/invites/invite_create_page.dart
Normal file
166
mobile/lib/features/invites/invite_create_page.dart
Normal 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: ', '');
|
||||
@@ -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'^-|-$'), '');
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
424
mobile/lib/features/invites/invite_create_provider.dart
Normal file
424
mobile/lib/features/invites/invite_create_provider.dart
Normal 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),
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
127
mobile/lib/shared/community/community_membership_provider.dart
Normal file
127
mobile/lib/shared/community/community_membership_provider.dart
Normal 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;
|
||||
@@ -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() =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
84
mobile/lib/shared/widgets/buzz_action_tile.dart
Normal file
84
mobile/lib/shared/widgets/buzz_action_tile.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
448
mobile/test/features/invites/invite_create_page_test.dart
Normal file
448
mobile/test/features/invites/invite_create_page_test.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
164
mobile/test/features/invites/invite_create_provider_test.dart
Normal file
164
mobile/test/features/invites/invite_create_provider_test.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
109
mobile/test/features/settings/settings_page_test.dart
Normal file
109
mobile/test/features/settings/settings_page_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: '',
|
||||
);
|
||||
Reference in New Issue
Block a user