From bf4a1635103e4388d1656722aa136ea5b41b07ce Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 20 Oct 2026 15:14:29 -0700 Subject: [PATCH] fix(mobile): lazily lay out channel member groups (#8437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lib/features/channels/members_sheet.dart | 101 +++++---- .../members_sheet/member_section.dart | 75 ++++++ .../channels/channel_detail_page_test.dart | 14 +- .../features/channels/members_sheet_test.dart | 213 ++++++++++++++++++ 4 files changed, 344 insertions(+), 59 deletions(-) create mode 100644 mobile/lib/features/channels/members_sheet/member_section.dart create mode 100644 mobile/test/features/channels/members_sheet_test.dart diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index bbfceddb60..7650713200 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -19,6 +19,8 @@ import 'channel.dart'; import 'channel_identity_names_provider.dart'; import 'channel_management_provider.dart'; +part 'members_sheet/member_section.dart'; + class MembersSheet extends HookConsumerWidget { final Channel channel; final String? currentPubkey; @@ -94,66 +96,71 @@ class MembersSheet extends HookConsumerWidget { key: const ValueKey('members-sheet-viewport'), constraints: BoxConstraints(maxHeight: 400 + bottomClearance), child: membersAsync.when( - data: (_) => ListView( + data: (_) => CustomScrollView( key: const ValueKey('members-sheet-list'), + semanticChildCount: allMembers.length, shrinkWrap: true, - padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance), - children: [ + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), if (people.isNotEmpty) - SheetActionSection( + _MemberSection( label: 'People · ${people.length}', - dividerIndent: Grid.xs + 40 + Grid.xs, - children: [ - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - userStatus: statusCache[member.pubkey.toLowerCase()], - onActivityTap: onMemberTap == null - ? null - : () => onMemberTap!(context, member.pubkey), - ), - ], + itemCount: people.length, + itemBuilder: (context, index) { + final member = people[index]; + return _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + userStatus: statusCache[member.pubkey.toLowerCase()], + onActivityTap: onMemberTap == null + ? null + : () => onMemberTap!(context, member.pubkey), + ); + }, ), if (bots.isNotEmpty) - SheetActionSection( + _MemberSection( label: 'Agents · ${bots.length}', - dividerIndent: Grid.xs + 40 + Grid.xs, - children: [ - for (final bot in bots) - _MemberTile( - member: bot, - currentPubkey: currentPubkey, - profile: userCache[bot.pubkey.toLowerCase()], - canManage: canManage, - isSelf: false, - channelId: channel.id, - isWorking: typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ), - onViewActivity: () => openActivity(bot), - onActivityTap: - typingBotPubkeys.contains(bot.pubkey.toLowerCase()) - ? () => openActivity(bot) - : null, + semanticIndexOffset: people.length, + itemCount: bots.length, + itemBuilder: (context, index) { + final bot = bots[index]; + return _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + isWorking: typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), ), - ], + onViewActivity: () => openActivity(bot), + onActivityTap: + typingBotPubkeys.contains(bot.pubkey.toLowerCase()) + ? () => openActivity(bot) + : null, + ); + }, ), if (people.isEmpty && bots.isEmpty) - Center( - child: Text( - 'No members found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + SliverToBoxAdapter( + child: Center( + child: Text( + 'No members found.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), ), ), + SliverToBoxAdapter(child: SizedBox(height: bottomClearance)), ], ), loading: () => const Center( diff --git a/mobile/lib/features/channels/members_sheet/member_section.dart b/mobile/lib/features/channels/members_sheet/member_section.dart new file mode 100644 index 0000000000..805a667abc --- /dev/null +++ b/mobile/lib/features/channels/members_sheet/member_section.dart @@ -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, + ), + ), + ], + ), + ); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index c05a9a6b99..21202aaf83 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3497,23 +3497,13 @@ void main() { await tester.tap(seeAllRow); await tester.pumpAndSettle(); // Verify each group when it enters the lazy sheet viewport. - expect( - find.byWidgetPredicate( - (widget) => widget is AppListCard && widget.label == 'People · 6', - ), - findsOneWidget, - ); + expect(find.text('People · 6'), findsOneWidget); await tester.drag( find.byKey(const ValueKey('members-sheet-list')), const Offset(0, -500), ); await tester.pumpAndSettle(); - expect( - find.byWidgetPredicate( - (widget) => widget is AppListCard && widget.label == 'Agents · 1', - ), - findsOneWidget, - ); + expect(find.text('Agents · 1'), findsOneWidget); expect(find.text('Agent'), findsOneWidget); expect(find.text('Bot'), findsNothing); }); diff --git a/mobile/test/features/channels/members_sheet_test.dart b/mobile/test/features/channels/members_sheet_test.dart new file mode 100644 index 0000000000..e464d9efeb --- /dev/null +++ b/mobile/test/features/channels/members_sheet_test.dart @@ -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 build() => {}; + @override + Future preload(List pubkeys) async => true; +} + +class _Statuses extends UserStatusCacheNotifier { + @override + Map build() => {}; + @override + void track(List pubkeys) {} +} + +String _key(int index) => index.toRadixString(16).padLeft(64, '0'); + +Future _pump( + WidgetTester tester, + List members, { + ValueChanged? 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 _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( + find.ancestor( + of: find.text('Agent 2'), + matching: find.byType(IndexedSemantics), + ), + ); + expect(agentIndex.index, 2); + expect( + tester + .widget( + 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); + }); +}