fix(mobile): lazily lay out channel member groups (#8437)

Opening a large channel member sheet mounted and laid out every person
in a grouped column. A captured Debug simulator entrance spent 242 ms in
the UI callback, including 223 ms in layout.

Build each person and agent as a lazy sliver row while preserving the
grouped cards, names, statuses, management menus, and activity actions.
Screen-reader row indices continue across both groups. A separate
formatting-only commit fixes an existing carousel-test formatter
discrepancy. No duplicate member-sheet performance PR or issue found.

### Validation

- Added a 200-member widget regression: the original mounts 100 people
initially; the fix mounts fewer than 20 nearby rows and reaches the
final person and agent.
- Added grouping, compact sizing, empty state, member tap identity,
accessible indices, and scrolled member/agent menu checks. Existing
channel-details “See all” navigation test passes.
- Mobile formatting, static analysis, and the full mobile test suite
passed on the committed tree.
- Synthetic before/after rendering is pixel-identical at the same
viewport.
- Populated native iOS 26.5 simulator workflow: open members, scroll
through people and agents to the end, then close. Final installed build
passed this workflow.

Debug, accessibility automation, profiler overhead, different Flutter
versions, and shared-host load limit timing conclusions. This removes
eager offscreen layout; it does not establish a controlled end-to-end
speedup or release-device performance.

### Visual check

Synthetic member sheet; before and after are pixel-identical at this
viewport.

![Synthetic member
groups](https://raw.githubusercontent.com/block/buzz/c327b1d973e6ec54a09358e8f6794dc0a2587304/pr-8437--members.png)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-20 15:14:29 -07:00
committed by GitHub
parent a93f3282cc
commit bf4a163510
4 changed files with 344 additions and 59 deletions

View File

@@ -19,6 +19,8 @@ import 'channel.dart';
import 'channel_identity_names_provider.dart'; import 'channel_identity_names_provider.dart';
import 'channel_management_provider.dart'; import 'channel_management_provider.dart';
part 'members_sheet/member_section.dart';
class MembersSheet extends HookConsumerWidget { class MembersSheet extends HookConsumerWidget {
final Channel channel; final Channel channel;
final String? currentPubkey; final String? currentPubkey;
@@ -94,66 +96,71 @@ class MembersSheet extends HookConsumerWidget {
key: const ValueKey('members-sheet-viewport'), key: const ValueKey('members-sheet-viewport'),
constraints: BoxConstraints(maxHeight: 400 + bottomClearance), constraints: BoxConstraints(maxHeight: 400 + bottomClearance),
child: membersAsync.when( child: membersAsync.when(
data: (_) => ListView( data: (_) => CustomScrollView(
key: const ValueKey('members-sheet-list'), key: const ValueKey('members-sheet-list'),
semanticChildCount: allMembers.length,
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance), slivers: [
children: [ const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)),
if (people.isNotEmpty) if (people.isNotEmpty)
SheetActionSection( _MemberSection(
label: 'People · ${people.length}', label: 'People · ${people.length}',
dividerIndent: Grid.xs + 40 + Grid.xs, itemCount: people.length,
children: [ itemBuilder: (context, index) {
for (final member in people) final member = people[index];
_MemberTile( return _MemberTile(
member: member, member: member,
currentPubkey: currentPubkey, currentPubkey: currentPubkey,
profile: userCache[member.pubkey.toLowerCase()], profile: userCache[member.pubkey.toLowerCase()],
canManage: canManage, canManage: canManage,
isSelf: isSelf:
member.pubkey.toLowerCase() == member.pubkey.toLowerCase() ==
currentPubkey?.toLowerCase(), currentPubkey?.toLowerCase(),
channelId: channel.id, channelId: channel.id,
userStatus: statusCache[member.pubkey.toLowerCase()], userStatus: statusCache[member.pubkey.toLowerCase()],
onActivityTap: onMemberTap == null onActivityTap: onMemberTap == null
? null ? null
: () => onMemberTap!(context, member.pubkey), : () => onMemberTap!(context, member.pubkey),
), );
], },
), ),
if (bots.isNotEmpty) if (bots.isNotEmpty)
SheetActionSection( _MemberSection(
label: 'Agents · ${bots.length}', label: 'Agents · ${bots.length}',
dividerIndent: Grid.xs + 40 + Grid.xs, semanticIndexOffset: people.length,
children: [ itemCount: bots.length,
for (final bot in bots) itemBuilder: (context, index) {
_MemberTile( final bot = bots[index];
member: bot, return _MemberTile(
currentPubkey: currentPubkey, member: bot,
profile: userCache[bot.pubkey.toLowerCase()], currentPubkey: currentPubkey,
canManage: canManage, profile: userCache[bot.pubkey.toLowerCase()],
isSelf: false, canManage: canManage,
channelId: channel.id, isSelf: false,
isWorking: typingBotPubkeys.contains( channelId: channel.id,
bot.pubkey.toLowerCase(), isWorking: typingBotPubkeys.contains(
), bot.pubkey.toLowerCase(),
onViewActivity: () => openActivity(bot),
onActivityTap:
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
? () => openActivity(bot)
: null,
), ),
], onViewActivity: () => openActivity(bot),
onActivityTap:
typingBotPubkeys.contains(bot.pubkey.toLowerCase())
? () => openActivity(bot)
: null,
);
},
), ),
if (people.isEmpty && bots.isEmpty) if (people.isEmpty && bots.isEmpty)
Center( SliverToBoxAdapter(
child: Text( child: Center(
'No members found.', child: Text(
style: context.textTheme.bodySmall?.copyWith( 'No members found.',
color: context.colors.onSurfaceVariant, style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
), ),
), ),
), ),
SliverToBoxAdapter(child: SizedBox(height: bottomClearance)),
], ],
), ),
loading: () => const Center( loading: () => const Center(

View File

@@ -0,0 +1,75 @@
part of '../members_sheet.dart';
/// The member card is split into independently laid-out rows so a large group
/// cannot force every offscreen member to mount during the sheet entrance.
class _MemberSection extends StatelessWidget {
const _MemberSection({
required this.label,
required this.itemCount,
required this.itemBuilder,
this.semanticIndexOffset = 0,
});
final String label;
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final int semanticIndexOffset;
@override
Widget build(BuildContext context) => SliverPadding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
sliver: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs),
child: Text(
label,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Material(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.vertical(
top: index == 0
? const Radius.circular(Radii.container)
: Radius.zero,
bottom: index == itemCount - 1
? const Radius.circular(Radii.container)
: Radius.zero,
),
clipBehavior: Clip.antiAlias,
child: ListTileTheme.merge(
contentPadding: const EdgeInsets.symmetric(horizontal: Grid.xs),
minLeadingWidth: 22,
horizontalTitleGap: Grid.xs,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (index > 0)
Divider(
height: 1,
thickness: 1,
indent: Grid.xs + 40 + Grid.xs,
endIndent: Grid.xs,
color: context.colors.onSurface.withValues(alpha: 0.07),
),
itemBuilder(context, index),
],
),
),
),
childCount: itemCount,
semanticIndexOffset: semanticIndexOffset,
),
),
],
),
);
}

View File

@@ -3497,23 +3497,13 @@ void main() {
await tester.tap(seeAllRow); await tester.tap(seeAllRow);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify each group when it enters the lazy sheet viewport. // Verify each group when it enters the lazy sheet viewport.
expect( expect(find.text('People · 6'), findsOneWidget);
find.byWidgetPredicate(
(widget) => widget is AppListCard && widget.label == 'People · 6',
),
findsOneWidget,
);
await tester.drag( await tester.drag(
find.byKey(const ValueKey('members-sheet-list')), find.byKey(const ValueKey('members-sheet-list')),
const Offset(0, -500), const Offset(0, -500),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(find.text('Agents · 1'), findsOneWidget);
find.byWidgetPredicate(
(widget) => widget is AppListCard && widget.label == 'Agents · 1',
),
findsOneWidget,
);
expect(find.text('Agent'), findsOneWidget); expect(find.text('Agent'), findsOneWidget);
expect(find.text('Bot'), findsNothing); expect(find.text('Bot'), findsNothing);
}); });

View File

@@ -0,0 +1,213 @@
import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_identity_names_provider.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/members_sheet.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_cache_provider.dart';
import 'package:buzz/shared/identity_names/identity_names.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class _Cache extends UserCacheNotifier {
@override
Map<String, UserProfile> build() => {};
@override
Future<bool> preload(List<String> pubkeys) async => true;
}
class _Statuses extends UserStatusCacheNotifier {
@override
Map<String, UserStatus?> build() => {};
@override
void track(List<String> pubkeys) {}
}
String _key(int index) => index.toRadixString(16).padLeft(64, '0');
Future<void> _pump(
WidgetTester tester,
List<ChannelMember> members, {
ValueChanged<String>? onTap,
}) async {
final names = IdentityNameSources.empty.scope(
members.map((m) => m.pubkey),
fallbackNames: {for (final m in members) m.pubkey: m.displayName!},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
channelMembersProvider('channel').overrideWith((ref) async => members),
channelIdentityNamesProvider('channel').overrideWith((ref) => names),
userCacheProvider.overrideWith(_Cache.new),
userStatusCacheProvider.overrideWith(_Statuses.new),
workingBotPubkeysProvider('channel').overrideWith((ref) => {}),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
child: MembersSheet(
channel: Channel(
id: 'channel',
name: 'Test',
channelType: 'stream',
visibility: 'open',
description: '',
createdBy: _key(0),
createdAt: DateTime(2026),
memberCount: members.length,
),
currentPubkey: _key(0),
onMemberTap: (_, key) => onTap?.call(key),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
List<ChannelMember> _roster(int people, int bots) => [
for (var i = 0; i < people + bots; i++)
ChannelMember(
pubkey: _key(i),
role: i == 0
? 'owner'
: i < people
? 'member'
: 'bot',
joinedAt: DateTime(2026),
displayName: i < people ? 'Person $i' : 'Agent $i',
),
];
void main() {
testWidgets('large groups mount nearby rows and can reach the final agent', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pump(tester, _roster(100, 100));
expect(find.text('People · 100'), findsOneWidget);
expect(find.text('You'), findsOneWidget);
expect(find.byType(ListTile).evaluate().length, lessThan(20));
expect(find.text('Person 99'), findsNothing);
final scrollable = find.descendant(
of: find.byKey(const ValueKey('members-sheet-list')),
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(
find.text('Person 99'),
300,
scrollable: scrollable,
maxScrolls: 100,
);
expect(find.text('Person 99'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('Agent 199'),
300,
scrollable: scrollable,
maxScrolls: 100,
);
expect(find.text('Agent 199'), findsOneWidget);
expect(find.byType(ListTile).evaluate().length, lessThan(20));
expect(tester.takeException(), isNull);
});
testWidgets('short list keeps grouping, self label and person tap identity', (
tester,
) async {
String? selected;
final semantics = tester.ensureSemantics();
await _pump(tester, _roster(2, 1), onTap: (key) => selected = key);
expect(find.text('People · 2'), findsOneWidget);
expect(find.text('Agents · 1'), findsOneWidget);
final agentIndex = tester.widget<IndexedSemantics>(
find.ancestor(
of: find.text('Agent 2'),
matching: find.byType(IndexedSemantics),
),
);
expect(agentIndex.index, 2);
expect(
tester
.widget<CustomScrollView>(
find.byKey(const ValueKey('members-sheet-list')),
)
.semanticChildCount,
3,
);
expect(find.text('You'), findsOneWidget);
expect(find.bySemanticsLabel(RegExp('Person 1')), findsWidgets);
await tester.tap(find.text('Person 1'));
expect(selected, _key(1));
semantics.dispose();
expect(
tester.getSize(find.byKey(const ValueKey('members-sheet-list'))).height,
lessThan(400),
);
expect(tester.takeException(), isNull);
});
testWidgets('owner actions stay available after lazy scrolling', (
tester,
) async {
await _pump(tester, _roster(40, 1));
final scrollable = find.descendant(
of: find.byKey(const ValueKey('members-sheet-list')),
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(
find.text('Person 39'),
300,
scrollable: scrollable,
maxScrolls: 50,
);
final row = find.ancestor(
of: find.text('Person 39'),
matching: find.byType(ListTile),
);
await tester.tap(
find.descendant(of: row, matching: find.byIcon(BuzzIcons.ellipsis)),
);
await tester.pumpAndSettle();
expect(find.text('Role'), findsOneWidget);
expect(find.text('Remove from channel'), findsOneWidget);
// Inspect only: do not invoke a membership mutation.
Navigator.of(tester.element(find.text('Role'))).pop();
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Agent 40'),
100,
scrollable: scrollable,
);
final agent = find.ancestor(
of: find.text('Agent 40'),
matching: find.byType(ListTile),
);
await tester.tap(
find.descendant(of: agent, matching: find.byIcon(BuzzIcons.ellipsis)),
);
await tester.pumpAndSettle();
expect(find.text('View activity'), findsOneWidget);
expect(find.text('Role'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('empty roster retains empty message', (tester) async {
await _pump(tester, []);
expect(find.text('No members found.'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}