mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
## 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>
449 lines
16 KiB
Dart
449 lines
16 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|