fix(mobile): simplify and refine the quick actions menu (#8491)

Keeps the mobile + menu focused on Create channel and New direct
message, with 8-point facing corners, a 6-point gap, and the active
Home/Solo tab fill on iOS.

Moves Browse channels directly above the channel list, including its
empty state, preserving open-channel discovery, retry, and Join. Adds a
cold-start journey through the real channel loader and join action with
relay boundaries faked.

Validation: mobile formatting/analysis and full mobile test suite;
mutation check confirms the new journey fails when directory loading is
removed. The original menu styling was tried on a physical iPhone; the
replacement Browse entry has widget-journey coverage and has not yet
been tried on device.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-10-20 17:38:29 -04:00
committed by GitHub
parent a5767c67de
commit ca35114209
7 changed files with 354 additions and 171 deletions

View File

@@ -2,6 +2,7 @@ import 'channel_unread.dart';
import '../../shared/widgets/collapsible_section_body.dart';
import '../../shared/widgets/compose_morph_icon.dart';
import '../../shared/me/me_sync.dart';
import 'dart:async';
import 'dart:io';
import 'dart:math' show max, min, pi;
@@ -77,7 +78,7 @@ part 'channels_page/quick_actions.dart';
part 'channels_page/quick_actions_launcher.dart';
part 'channels_page/header_actions.dart';
enum _QuickAction { createChannel, newDm, browseChannels }
enum _QuickAction { createChannel, newDm }
const double _kChannelSectionInset = Grid.gutter;
const double _kChannelLeadingWidth = 22.0;

View File

@@ -254,6 +254,25 @@ class _SliverChannelsList extends HookConsumerWidget {
),
sliver: SliverList.list(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const Key('browse-channels-button'),
icon: const Icon(BuzzIcons.compass),
label: const Text('Browse channels'),
onPressed: () => showBuzzModalBottomSheet<void>(
context: context,
title: 'Browse channels',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _BrowseChannelsSheet(),
),
),
),
),
if (visibleChannels.isEmpty)
const _EmptyState()
else ...[

View File

@@ -7,7 +7,7 @@ const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphOpenBounce = 0.14;
const double _kMorphCloseBounce = 0.06;
const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 216;
const double _kMorphOpenHeight = 142;
const double _kMorphOpenRadius = mobileTabBarHeight / 2;
const double _kMorphSlide = 40;
const double _kMorphScale = 0.97;
@@ -300,23 +300,24 @@ class _QuickActionsMenu extends StatelessWidget {
_QuickActionItem(
icon: BuzzIcons.hash,
title: 'Create channel',
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_kQuickActionCardRadius),
bottom: Radius.circular(8),
),
key: const Key('quick-action-create-channel-card'),
onTap: () => onSelected(_QuickAction.createChannel),
),
const SizedBox(height: Grid.xxs),
const SizedBox(height: Grid.xxs - 2),
_QuickActionItem(
icon: BuzzIcons.messagesSquare,
title: 'New direct message',
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
bottom: Radius.circular(_kQuickActionCardRadius),
),
key: const Key('quick-action-new-dm-card'),
onTap: () => onSelected(_QuickAction.newDm),
),
const SizedBox(height: Grid.xxs),
_QuickActionItem(
icon: BuzzIcons.compass,
title: 'Browse channels',
key: const Key('quick-action-browse-channels-card'),
onTap: () => onSelected(_QuickAction.browseChannels),
),
],
),
);
@@ -327,12 +328,14 @@ class _QuickActionItem extends StatelessWidget {
final IconData icon;
final String title;
final VoidCallback onTap;
final BorderRadius borderRadius;
const _QuickActionItem({
super.key,
required this.icon,
required this.title,
required this.onTap,
required this.borderRadius,
});
@override
@@ -340,7 +343,10 @@ class _QuickActionItem extends StatelessWidget {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
final foreground = isIos ? context.colors.onSurface : Colors.white;
final background = isIos
? context.controlSurface
? Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
context.controlSurface,
)
: Color.alphaBlend(
Colors.white.withValues(alpha: 0.1),
const Color(0xFF262626),
@@ -349,9 +355,7 @@ class _QuickActionItem extends StatelessWidget {
return Expanded(
child: Material(
color: background,
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
),
shape: RoundedSuperellipseBorder(borderRadius: borderRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,

View File

@@ -121,15 +121,6 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
if (opened != null && context.mounted) {
await openChannel(opened);
}
case _QuickAction.browseChannels:
await showBuzzModalBottomSheet<void>(
context: context,
title: 'Browse channels',
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _BrowseChannelsSheet(),
);
}
}

View File

@@ -59,6 +59,7 @@ import 'package:buzz/shared/widgets/skeleton.dart';
import '../../helpers/thread_ids.dart';
part 'channels_page_test/presence_tests.dart';
part 'channels_page_test/discovery_journey_tests.dart';
part 'channels_page_test/unread_readiness_tests.dart';
void main() {
@@ -157,6 +158,7 @@ void main() {
}
_communityUnreadLandingTests(buildTestable);
_discoveryJourneyTests(buildTestable);
final testChannels = [
Channel(
@@ -2949,8 +2951,8 @@ void main() {
}
await tester.pumpAndSettle();
expect(largestHeight, greaterThan(216));
expect(tester.getSize(surface).height, closeTo(216, 0.01));
expect(largestHeight, greaterThan(142));
expect(tester.getSize(surface).height, closeTo(142, 0.01));
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
final surfaceRect = tester.getRect(surface);
expect(surfaceRect.left, closeTo(20, 0.01));
@@ -2963,23 +2965,15 @@ void main() {
const Key('quick-action-create-channel-card'),
);
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
final browseCard = find.byKey(
const Key('quick-action-browse-channels-card'),
);
final createRect = tester.getRect(createCard);
final dmRect = tester.getRect(dmCard);
final browseRect = tester.getRect(browseCard);
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
expect(browseRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - browseRect.right, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
expect(browseRect.top - dmRect.bottom, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(6, 0.01));
expect(dmRect.width, createRect.width);
expect(browseRect.width, createRect.width);
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
final expectedCardColor = Color.alphaBlend(
@@ -2992,25 +2986,21 @@ void main() {
final dmMaterial = tester.widget<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
final browseMaterial = tester.widget<Material>(
find.descendant(of: browseCard, matching: find.byType(Material)).first,
);
expect(createMaterial.color, expectedCardColor);
expect(dmMaterial.color, expectedCardColor);
expect(browseMaterial.color, expectedCardColor);
expect(
((createMaterial.shape as RoundedSuperellipseBorder).borderRadius
as BorderRadius)
.topLeft
.x,
closeTo(20, 0.01),
(createMaterial.shape as RoundedSuperellipseBorder).borderRadius,
const BorderRadius.vertical(
top: Radius.circular(20),
bottom: Radius.circular(8),
),
);
expect(
((dmMaterial.shape as RoundedSuperellipseBorder).borderRadius
as BorderRadius)
.topLeft
.x,
closeTo(20, 0.01),
(dmMaterial.shape as RoundedSuperellipseBorder).borderRadius,
const BorderRadius.vertical(
top: Radius.circular(8),
bottom: Radius.circular(20),
),
);
expect(find.text('Start a new stream channel'), findsNothing);
@@ -3023,8 +3013,8 @@ void main() {
16,
);
expect(
tester.widget<Text>(find.text('Browse channels')).style?.fontSize,
16,
find.byKey(const Key('quick-action-browse-channels-card')),
findsNothing,
);
expect(find.text('Message one or more people'), findsNothing);
});
@@ -3087,11 +3077,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.tap(
find.byKey(const Key('quick-action-browse-channels-card')),
);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(
@@ -3123,11 +3109,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.tap(
find.byKey(const Key('quick-action-browse-channels-card')),
);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(find.text('No open channels available to join.'), findsOneWidget);
@@ -3162,11 +3144,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.tap(
find.byKey(const Key('quick-action-browse-channels-card')),
);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(find.text('Couldn’t load open channels.'), findsOneWidget);
@@ -3213,11 +3191,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.tap(
find.byKey(const Key('quick-action-browse-channels-card')),
);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
@@ -3269,11 +3243,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
await tester.tap(
find.byKey(const Key('quick-action-browse-channels-card')),
);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(
@@ -4120,105 +4090,11 @@ class _FakeNotifier extends ChannelsNotifier {
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _RetryingDirectoryNotifier extends ChannelsNotifier {
_RetryingDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
}
@override
Future<void> retryDirectory() async {
retryCount++;
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await Future<void>.delayed(Duration.zero);
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
class _SupersededDirectoryNotifier extends ChannelsNotifier {
_SupersededDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
final _directoryCompletion = Completer<void>();
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await _directoryCompletion.future;
}
/// Mirrors an ordinary refresh invalidating the active directory request.
void supersedeLoadingDirectory() {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
_directoryCompletion.complete();
}
@override
Future<void> retryDirectory() async {
retryCount++;
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
String _activeDirectoryScope(Ref ref) => channelDirectoryScope(
ref.read(relayConfigProvider).baseUrl,
ref.read(myPubkeyProvider),
);
class _RecordingChannelActions extends ChannelActions {
_RecordingChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
final List<String> joinedChannelIds = [];
@override
Future<void> joinChannel(String channelId) async {
joinedChannelIds.add(channelId);
}
}
class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier {
_FakeChannelSectionsNotifier(this._store);
@@ -4424,3 +4300,97 @@ class _CompletedPairingNotifier extends PairingNotifier {
@override
PairingState build() => const PairingState(status: PairingStatus.success);
}
class _RetryingDirectoryNotifier extends ChannelsNotifier {
_RetryingDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
}
@override
Future<void> retryDirectory() async {
retryCount++;
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await Future<void>.delayed(Duration.zero);
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
class _SupersededDirectoryNotifier extends ChannelsNotifier {
_SupersededDirectoryNotifier({
required this.initialChannels,
required this.retriedChannels,
});
final List<Channel> initialChannels;
final List<Channel> retriedChannels;
final _directoryCompletion = Completer<void>();
int retryCount = 0;
@override
Future<List<Channel>> build() async => initialChannels;
@override
Future<void> ensureDirectoryLoaded() async {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoading(_activeDirectoryScope(ref));
await _directoryCompletion.future;
}
/// Mirrors an ordinary refresh invalidating the active directory request.
void supersedeLoadingDirectory() {
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markError(_activeDirectoryScope(ref));
_directoryCompletion.complete();
}
@override
Future<void> retryDirectory() async {
retryCount++;
state = AsyncData(retriedChannels);
ref
.read(channelDirectoryLoadStatusProvider.notifier)
.markLoaded(_activeDirectoryScope(ref));
}
}
class _RecordingChannelActions extends ChannelActions {
_RecordingChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: 'self',
);
final List<String> joinedChannelIds = [];
@override
Future<void> joinChannel(String channelId) async {
joinedChannelIds.add(channelId);
}
}

View File

@@ -0,0 +1,192 @@
part of '../channels_page_test.dart';
void _discoveryJourneyTests(
Widget Function({required List<Override> overrides}) buildTestable,
) {
testWidgets(
'zero-membership user retries discovery and joins a conversation',
(tester) async {
final session = _DiscoveryJourneySession();
await tester.pumpWidget(
buildTestable(
overrides: [
relaySessionProvider.overrideWith(() => session),
myPubkeyProvider.overrideWith((ref) => 'self'),
channelActionsProvider.overrideWith(
(ref) => ChannelActions(
ref: ref,
session: session,
signedEventRelay: _DiscoveryJourneyRelay(session),
currentPubkey: 'self',
),
),
],
),
);
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(
tester.element(find.byType(ChannelsPage)),
);
expect(container.read(channelsProvider).requireValue, isEmpty);
expect(session.directoryRequests, 0);
await tester.tap(find.byKey(const Key('browse-channels-button')));
await tester.pumpAndSettle();
expect(find.text('Couldn’t load open channels.'), findsOneWidget);
await tester.tap(find.byKey(const Key('browse-channels-retry')));
await tester.pumpAndSettle();
expect(find.text('community-help'), findsOneWidget);
expect(find.text('private-planning'), findsNothing);
expect(find.text('old-channel'), findsNothing);
expect(find.text('hidden-dm'), findsNothing);
expect(
container.read(channelsProvider).requireValue.every((c) => !c.isMember),
isTrue,
);
await tester.tap(
find.byKey(const Key('browse-channel-join-$_discoveryId')),
);
await tester.pumpAndSettle();
expect(session.joined, isTrue);
expect(find.byType(BottomSheet), findsNothing);
expect(find.text('community-help'), findsOneWidget);
expect(
container
.read(channelsProvider)
.requireValue
.singleWhere((channel) => channel.id == _discoveryId)
.isMember,
isTrue,
);
// Discovery remains available after acquiring a membership.
expect(find.byKey(const Key('browse-channels-button')), findsOneWidget);
},
);
}
const _discoveryId = '11111111-1111-4111-8111-111111111111';
class _DiscoveryJourneySession extends _ReconnectingRelaySession {
_DiscoveryJourneySession() : super(initialStatus: SessionStatus.connected);
bool joined = false;
int directoryRequests = 0;
NostrEvent metadata(
String id,
String name, {
String type = 'stream',
bool private = false,
bool archived = false,
}) => NostrEvent(
id: 'meta-$id',
pubkey: 'creator',
createdAt: 1,
kind: 39000,
tags: [
['d', id],
['name', name],
['t', type],
[private ? 'private' : 'public'],
if (archived) ['archived', 'true'],
],
content: '',
sig: 'sig',
);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async {
final result = <NostrEvent>[];
for (final filter in filters) {
if (filter.until != null) continue;
if (filter.kinds.contains(39000) && !filter.tags.containsKey('#d')) {
directoryRequests++;
if (directoryRequests == 1) throw Exception('Directory unavailable');
result.addAll([
metadata(_discoveryId, 'community-help'),
metadata(
'22222222-2222-4222-8222-222222222222',
'private-planning',
private: true,
),
metadata(
'33333333-3333-4333-8333-333333333333',
'old-channel',
archived: true,
),
metadata(
'44444444-4444-4444-8444-444444444444',
'hidden-dm',
type: 'dm',
),
]);
} else {
result.addAll(await fetchHistory(filter));
}
}
return result;
}
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (filter.kinds.contains(39002) && joined) {
return [
NostrEvent(
id: 'membership',
pubkey: 'creator',
createdAt: 2,
kind: 39002,
tags: [
['d', _discoveryId],
['p', 'self'],
],
content: '',
sig: 'sig',
),
];
}
if (filter.kinds.contains(39000) &&
(filter.tags['#d'] ?? []).contains(_discoveryId)) {
return [metadata(_discoveryId, 'community-help')];
}
return [];
}
}
// Fake only the signed network boundary; the widget, ChannelsNotifier and
// ChannelActions.joinChannel/refresh are the production implementations.
class _DiscoveryJourneyRelay extends SignedEventRelay {
_DiscoveryJourneyRelay(this.session) : super(session: session, nsec: null);
final _DiscoveryJourneySession session;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent)? onSigned,
}) async {
expect(kind, 9021);
expect(tags, [
['h', _discoveryId],
]);
session.joined = true;
return NostrEvent(
id: 'join',
pubkey: 'self',
createdAt: 2,
kind: kind,
tags: tags,
content: '',
sig: 'sig',
);
}
}

View File

@@ -165,7 +165,10 @@ void main() {
expect(tester.getSize(launcherGlass).width, greaterThan(56));
expect(find.text('Create channel').hitTestable(), findsOneWidget);
expect(find.text('New direct message').hitTestable(), findsOneWidget);
expect(find.text('Browse channels').hitTestable(), findsOneWidget);
expect(
find.byKey(const Key('quick-action-browse-channels-card')),
findsNothing,
);
await tester.tapAt(const Offset(8, 8));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
@@ -270,7 +273,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Create channel'), findsOneWidget);
expect(find.text('New direct message'), findsOneWidget);
expect(find.text('Browse channels'), findsOneWidget);
expect(
find.byKey(const Key('quick-action-browse-channels-card')),
findsNothing,
);
});
testWidgets('keeps the Buzz backdrop behind the scalable Home screen', (