mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
Long-pressing the Buzz icon on iOS now offers three quick actions: - **New chat** opens a new solo chat (the same draft as New › New chat). - **New DM** opens the New DM picker. - **Message <Bestie>** opens the DM with the current community's Bestie, using the agent's display name (for example "Message astra"). It's hidden when the community has no Bestie, and it updates when the Bestie, its name or the community changes. | Before | After | | --- | --- | | <img alt="before" src="https://github.com/user-attachments/assets/393c3fb6-fe64-4505-a7a4-12714d8bf1df" width="300"> | <img alt="after" src="https://github.com/user-attachments/assets/1c57df92-6fed-4fee-9ce8-e9ea0ec70294" width="300"> | ### How it works Home publishes the items through a small native bridge (`buzz/home_screen_shortcuts`), which sets `UIApplication.shortcutItems`. A tap goes through the scene delegate. The bridge keeps the latest one until Dart has handled it, the same pattern push-notification taps use, so cold launches work too. Home runs it only once the app is active and the destination is ready. When a tap arrives, Home pops back to the first route once, so a shortcut never opens beneath a Flutter sheet or a conversation that was already open. A screen that refuses to close (for example a New chat whose first message is still sending) stays, and the tap is dropped. The one exception is the native iOS New channel form, which isn't a Flutter route: a shortcut tapped with it open runs underneath it. If a screen is opened while a tap is still waiting for its destination, the tap is dropped. A New DM tap while the New DM picker is open replaces the picker. Opening the Bestie DM now goes through one shared function, so the Home launcher button and the shortcut run identical code. A second open while the DM is still being looked up does nothing. Signing out clears the items. The shortcut wiring lives in its own leaf widget, so Home doesn't rebuild for it. Behavior change for the existing Home Bestie launcher: if you open another screen while it's still looking up the DM, the DM no longer opens over that screen. Behavior change for the native member picker (New DM, and Add members in channels): a picker reopened while the previous one is still closing now opens once the dismissal finishes, instead of failing with "Cannot open the member picker". ### Validation - Added tests: a Home journey covers the published titles (including the Bestie's name), a cold tap that waits for the app to resume and for New chat to be available, a Bestie tap made with another route open, a New chat tap while a draft is open, a New DM tap replacing an open picker, a waiting tap dropped once another screen opens, no Home rebuilds on lifecycle changes, and clearing the items when Home goes away. A launcher test covers a slow Bestie lookup, and a New chat test covers a shortcut tapped while the first message is still sending. I also broke the code that runs the shortcut and confirmed the journey fails. - Full mobile suite and `flutter analyze` pass. CI doesn't compile Swift on drafts, so `NativeAddMembersSheet.swift` was compiled with a local `flutter build ios --simulator` build. - iPhone 17 Pro simulator (iOS 27), signed in to a Builderlab community: from a cold launch, New chat opened the new-chat draft. Then, with the app warm, New DM opened the picker and Message <Bestie> replaced it with the Bestie DM. The recording stays in Buzz because it shows real account data. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
11 KiB
Dart
291 lines
11 KiB
Dart
import 'package:buzz/features/channels/default_agent_launcher.dart';
|
|
import 'package:buzz/features/agents/agents_page.dart';
|
|
import 'dart:io';
|
|
import 'package:buzz/shared/widgets/ios_message_create_button.dart';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:buzz/features/home/home_page.dart';
|
|
import 'package:buzz/features/profile/profile_provider.dart';
|
|
import 'package:buzz/features/profile/settings_profile_header.dart';
|
|
import 'package:buzz/features/profile/user_status_provider.dart';
|
|
import 'package:buzz/features/settings/settings_page.dart';
|
|
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:buzz/shared/profile/user_profile.dart';
|
|
import 'package:buzz/features/profile/user_status.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:flutter/foundation.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:nostr/nostr.dart' as nostr;
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
|
|
testWidgets(
|
|
'Settings overlays are visible above Home on ${platform.name}',
|
|
(tester) async {
|
|
if (Platform.environment.containsKey('SETTINGS_SCREENSHOTS')) {
|
|
await tester.runAsync(() async {
|
|
for (final font in {
|
|
'Inter': 'assets/fonts/InterVariable.ttf',
|
|
'BuzzTabler': 'assets/fonts/TablerIcons.ttf',
|
|
}.entries) {
|
|
await (FontLoader(
|
|
font.key,
|
|
)..addFont(rootBundle.load(font.value))).load();
|
|
}
|
|
});
|
|
}
|
|
debugDefaultTargetPlatformOverride = platform;
|
|
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
|
tester.view.physicalSize = const Size(390, 844);
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.padding = FakeViewPadding(top: 47, bottom: 34);
|
|
tester.view.viewPadding = FakeViewPadding(top: 47, bottom: 34);
|
|
addTearDown(tester.view.reset);
|
|
PackageInfo.setMockInitialValues(
|
|
appName: 'Buzz',
|
|
packageName: 'xyz.block.buzz',
|
|
version: '0.16.0',
|
|
buildNumber: '432',
|
|
buildSignature: '',
|
|
);
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final presence = _Presence();
|
|
final nativeChannels = <MethodChannel>[];
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform_views,
|
|
(call) async {
|
|
if (call.method == 'create') {
|
|
final args = call.arguments as Map;
|
|
final channel = MethodChannel(
|
|
'${args['viewType']}/${args['id']}',
|
|
);
|
|
nativeChannels.add(channel);
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
channel,
|
|
(_) async => null,
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(() {
|
|
for (final channel in [
|
|
SystemChannels.platform_views,
|
|
...nativeChannels,
|
|
]) {
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
channel,
|
|
null,
|
|
);
|
|
}
|
|
});
|
|
String? copied;
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
(call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copied = (call.arguments as Map)['text'] as String;
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(() {
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
null,
|
|
);
|
|
});
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
savedPrefsProvider.overrideWithValue(prefs),
|
|
relayConfigProvider.overrideWith(_Config.new),
|
|
appLifecycleProvider.overrideWith(_Lifecycle.new),
|
|
profileProvider.overrideWith(_Profile.new),
|
|
presenceProvider.overrideWith(() => presence),
|
|
userStatusProvider.overrideWith(_Status.new),
|
|
customEmojiListProvider.overrideWithValue(const []),
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
builder: (_, child) => RepaintBoundary(
|
|
key: const ValueKey('settings-test-capture'),
|
|
child: child!,
|
|
),
|
|
home: HomePage(
|
|
onAgents: showAgents,
|
|
openBestieConversation: (_, _, _) async {},
|
|
defaultAgentBuilder: (context) => DefaultAgentLauncher(
|
|
onChooseAgent: () => showAgents(context),
|
|
),
|
|
settingsPageBuilder: (_) => SettingsPage(
|
|
profileHeader: const SettingsProfileHeader(),
|
|
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
if (platform == TargetPlatform.iOS) {
|
|
tester
|
|
.widget<IosMessageCreateButton>(
|
|
find.byWidgetPredicate(
|
|
(w) =>
|
|
w is IosMessageCreateButton &&
|
|
w.accessibilityLabel == 'Community menu',
|
|
),
|
|
)
|
|
.actions
|
|
.firstWhere((action) => action.label == 'Settings')
|
|
.onPressed!();
|
|
} else {
|
|
await tester.tap(find.byTooltip('Community menu'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Settings'));
|
|
}
|
|
await tester.pumpAndSettle();
|
|
if (platform == TargetPlatform.iOS) {
|
|
expect(find.byType(HomePage), findsNothing);
|
|
expect(find.byType(HomePage, skipOffstage: false), findsOneWidget);
|
|
final menu = tester.widget<UiKitView>(
|
|
find.byKey(const ValueKey('settings-presence-menu')),
|
|
);
|
|
expect(menu.viewType, 'buzz/presence_menu');
|
|
expect(menu.creationParams, containsPair('presence', 'offline'));
|
|
final nativeMenu = nativeChannels.singleWhere(
|
|
(channel) => channel.name.startsWith('buzz/presence_menu/'),
|
|
);
|
|
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
|
nativeMenu.name,
|
|
const StandardMethodCodec().encodeMethodCall(
|
|
const MethodCall('selected', 'online'),
|
|
),
|
|
(_) {},
|
|
);
|
|
await tester.pumpAndSettle();
|
|
} else {
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('settings-presence-menu')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final online = find.byKey(const ValueKey('settings-presence-online'));
|
|
expect(online.hitTestable(), findsOneWidget);
|
|
final fade = tester.widget<FadeTransition>(
|
|
find.byKey(const ValueKey('activity-popover-fade')),
|
|
);
|
|
expect(fade.opacity.value, 1);
|
|
final menu = tester.getRect(
|
|
find.byKey(const ValueKey('settings-presence-popover')),
|
|
);
|
|
final anchor = tester.getRect(
|
|
find.byKey(const ValueKey('settings-presence-target')),
|
|
);
|
|
expect(menu.center.dx, closeTo(anchor.center.dx, 0.1));
|
|
expect(menu.top, closeTo(anchor.bottom + Grid.half, 0.1));
|
|
expect(menu.bottom, lessThanOrEqualTo(810));
|
|
await _capture(tester, '${platform.name}-availability');
|
|
await tester.tapAt(const Offset(20, 700));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.byKey(const ValueKey('settings-presence-popover')),
|
|
findsNothing,
|
|
);
|
|
expect(presence.selected, isEmpty);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('settings-presence-menu')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(online);
|
|
await tester.pumpAndSettle();
|
|
}
|
|
expect(presence.selected, ['online']);
|
|
await tester.tap(find.text('Copy public key (npub)'));
|
|
await tester.pumpAndSettle();
|
|
expect(copied, startsWith('npub1'));
|
|
expect(find.byType(SnackBar), findsNothing);
|
|
final settings = find.byType(SettingsPage);
|
|
final message = find.text('Public key copied');
|
|
expect(message, findsOneWidget);
|
|
// The shared toast is above the route, rather than inside its Scaffold.
|
|
expect(find.descendant(of: settings, matching: message), findsNothing);
|
|
final toastSurface = find
|
|
.ancestor(of: message, matching: find.byType(PhysicalShape))
|
|
.first;
|
|
expect(tester.getRect(toastSurface).top, greaterThanOrEqualTo(0));
|
|
expect(tester.getRect(toastSurface).bottom, lessThan(160));
|
|
await _capture(tester, '${platform.name}-copied');
|
|
Navigator.of(tester.element(settings)).pop();
|
|
await tester.pumpAndSettle();
|
|
await tester.pump(const Duration(seconds: 4));
|
|
await tester.pumpAndSettle();
|
|
expect(message, findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
debugDefaultTargetPlatformOverride = null;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Presence extends PresenceNotifier {
|
|
final selected = <String>[];
|
|
@override
|
|
Future<String> build() async => 'offline';
|
|
@override
|
|
Future<void> setPresence(String status) async {
|
|
selected.add(status);
|
|
state = AsyncData(status);
|
|
}
|
|
}
|
|
|
|
class _Profile extends ProfileNotifier {
|
|
@override
|
|
Future<UserProfile?> build() async =>
|
|
UserProfile(pubkey: 'aabb', displayName: 'Test');
|
|
}
|
|
|
|
class _Status extends UserStatusNotifier {
|
|
@override
|
|
Future<UserStatus?> build() async => null;
|
|
}
|
|
|
|
class _Config extends RelayConfigNotifier {
|
|
@override
|
|
RelayConfig build() => RelayConfig(
|
|
baseUrl: 'https://relay.test',
|
|
nsec: nostr.Keys(
|
|
'1111111111111111111111111111111111111111111111111111111111111111',
|
|
).nsec,
|
|
);
|
|
}
|
|
|
|
Future<void> _capture(WidgetTester tester, String name) async {
|
|
final directory = Platform.environment['SETTINGS_SCREENSHOTS'];
|
|
if (directory == null) return;
|
|
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
|
find.byKey(const ValueKey('settings-test-capture')),
|
|
);
|
|
await tester.runAsync(() async {
|
|
final image = await boundary.toImage(pixelRatio: 2);
|
|
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
await Directory(directory).create(recursive: true);
|
|
await File(
|
|
'$directory/$name.png',
|
|
).writeAsBytes(bytes!.buffer.asUint8List());
|
|
image.dispose();
|
|
});
|
|
}
|
|
|
|
class _Lifecycle extends AppLifecycleNotifier {
|
|
@override
|
|
AppLifecycleState build() => AppLifecycleState.resumed;
|
|
}
|