diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 74303c057..4378bb7ec 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -242,58 +242,61 @@ class _ThreadContent extends HookConsumerWidget { return Column( children: [ Expanded( - child: ListView( - padding: EdgeInsets.only( - top: frostedAppBarHeight(context), - bottom: Grid.xs, - ), - children: [ - _OriginalPost(post: post), - - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - children: [ - Icon( - LucideIcons.messageSquare, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.half), - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium?.copyWith( + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + sliver: SliverToBoxAdapter(child: _OriginalPost(post: post)), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xxs, + ), + child: Row( + children: [ + Icon( + LucideIcons.messageSquare, + size: 16, color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, ), - ), - ], + const SizedBox(width: Grid.half), + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ), - - // Reply list if (replies.isEmpty) - Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Text( - 'No replies yet. Be the first to respond.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Text( + 'No replies yet. Be the first to respond.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ) else - for (final reply in replies) - _ReplyRow( - reply: reply, + SliverList.builder( + itemCount: replies.length, + itemBuilder: (context, index) => _ReplyRow( + reply: replies[index], currentPubkey: currentPubkey, channelId: channelId, rootEventId: post.eventId, ), + ), + const SliverToBoxAdapter(child: SizedBox(height: Grid.xs)), ], ), ), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 0615a1963..5c9b1cb38 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -506,15 +506,9 @@ class _SearchBody extends ConsumerWidget { ); } - return ListView( + return CustomScrollView( key: const Key('search-results-list'), - padding: EdgeInsets.only( - bottom: - Grid.xl + - MediaQuery.paddingOf(context).bottom + - MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ + slivers: [ if (showChannels && state.channelResults.isNotEmpty) _ChannelsSection( channels: state.channelResults, @@ -532,15 +526,26 @@ class _SearchBody extends ConsumerWidget { onResultSelected: recordResultSelection, ), if (state.isLoading) - const Padding( - padding: EdgeInsets.all(Grid.sm), - child: Center( - child: BuzzLoadingIndicator( - size: 36, - semanticLabel: 'Loading more search results', + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center( + child: BuzzLoadingIndicator( + size: 36, + semanticLabel: 'Loading more search results', + ), ), ), ), + SliverToBoxAdapter( + child: SizedBox( + key: const Key('search-results-bottom-clearance'), + height: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, + ), + ), ], ); } @@ -652,58 +657,64 @@ class _ChannelsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Channels'), - for (final channel in channels) - ListTile( - key: ValueKey('search-channel-row-${channel.id}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: Icon( - channelIcon(channel), - key: ValueKey('search-channel-leading-${channel.id}'), - size: 20, - ), - title: Text( - channel.name, - key: ValueKey('search-channel-title-${channel.id}'), - style: contentListTitleTextStyle, - ), - subtitle: Text( - '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, + return SliverMainAxisGroup( + slivers: [ + const SliverToBoxAdapter(child: _SectionLabel(label: 'Channels')), + SliverList.builder( + itemCount: channels.length, + itemBuilder: (context, index) { + final channel = channels[index]; + return ListTile( + key: ValueKey('search-channel-row-${channel.id}'), + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, ), - ), - trailing: !channel.isMember && !channel.isDm - ? Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half + 2, - vertical: 3, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Text( - 'Open', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w600, - ), - ), - ) - : null, - onTap: () { - onResultSelected(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + leading: Icon( + channelIcon(channel), + key: ValueKey('search-channel-leading-${channel.id}'), + size: 20, + ), + title: Text( + channel.name, + key: ValueKey('search-channel-title-${channel.id}'), + style: contentListTitleTextStyle, + ), + subtitle: Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, ), - ); - }, - ), + ), + trailing: !channel.isMember && !channel.isDm + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + 2, + vertical: 3, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'Open', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + onTap: () { + onResultSelected(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ); + }, + ), ], ); } @@ -717,44 +728,50 @@ class _PeopleSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'People'), - for (final user in users) - ListTile( - key: ValueKey('search-person-row-${user.pubkey}'), - contentPadding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - leading: AvatarImage( - key: ValueKey('search-person-leading-${user.pubkey}'), - imageUrl: user.avatarUrl, - radius: 20, - fallback: Text(user.label.substring(0, 1).toUpperCase()), - ), - title: Text( - user.label, - key: ValueKey('search-person-title-${user.pubkey}'), - style: contentListTitleTextStyle, - ), - subtitle: Text( - user.secondaryLabel, - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, + return SliverMainAxisGroup( + slivers: [ + const SliverToBoxAdapter(child: _SectionLabel(label: 'People')), + SliverList.builder( + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return ListTile( + key: ValueKey('search-person-row-${user.pubkey}'), + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, ), - ), - onTap: () async { - onResultSelected(); - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [user.pubkey]); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + leading: AvatarImage( + key: ValueKey('search-person-leading-${user.pubkey}'), + imageUrl: user.avatarUrl, + radius: 20, + fallback: Text(user.label.substring(0, 1).toUpperCase()), + ), + title: Text( + user.label, + key: ValueKey('search-person-title-${user.pubkey}'), + style: contentListTitleTextStyle, + ), + subtitle: Text( + user.secondaryLabel, + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, ), - ); - }, - ), + ), + onTap: () async { + onResultSelected(); + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [user.pubkey]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ); + }, + ), ], ); } @@ -787,19 +804,23 @@ class _MessagesSection extends HookConsumerWidget { return null; }, [preloadPubkeysKey]); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionLabel(label: 'Messages'), - for (final hit in hits) - _MessageTile( - hit: hit, - authorProfile: profiles[hit.pubkey.toLowerCase()], - userCache: profiles, - channel: channels.where((c) => c.id == hit.channelId).firstOrNull, - currentPubkey: currentPubkey, - onResultSelected: onResultSelected, - ), + return SliverMainAxisGroup( + slivers: [ + const SliverToBoxAdapter(child: _SectionLabel(label: 'Messages')), + SliverList.builder( + itemCount: hits.length, + itemBuilder: (context, index) { + final hit = hits[index]; + return _MessageTile( + hit: hit, + authorProfile: profiles[hit.pubkey.toLowerCase()], + userCache: profiles, + channel: channels.where((c) => c.id == hit.channelId).firstOrNull, + currentPubkey: currentPubkey, + onResultSelected: onResultSelected, + ); + }, + ), ], ); } diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index f834bda64..278ba6476 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -588,6 +588,51 @@ void main() { expect(find.text('Bob'), findsOneWidget); }); + testWidgets('lazily builds forum replies', (tester) async { + _setSurfaceSize(tester, const Size(320, 640)); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final replies = [ + for (var index = 0; index < 200; index++) + ThreadReply( + eventId: 'reply-$index', + pubkey: 'bob', + content: 'Reply $index', + kind: 45003, + createdAt: 2000 + index, + channelId: _channelId, + tags: const [ + ['h', _channelId], + ], + depth: 1, + ), + ]; + + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: replies, + totalReplies: replies.length, + ), + users: const { + 'alice': _aliceProfile, + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pump(); + + expect(find.text('Reply 0'), findsOneWidget); + expect( + find.text('Reply 199'), + findsNothing, + reason: 'Offscreen forum replies must not be eagerly mounted.', + ); + }); + testWidgets('constrains post and reply timestamps at large text sizes', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 55645313c..5a2a62b95 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -544,6 +544,55 @@ void main() { expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); + testWidgets('lazily builds search result rows', (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final channels = [ + for (var index = 0; index < 200; index++) + Channel( + id: 'channel-$index', + name: 'channel-$index', + channelType: 'stream', + visibility: 'open', + description: 'Channel $index', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 1, + isMember: true, + ), + ]; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier( + SearchState(query: 'channel', channelResults: channels), + ), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pump(); + + expect( + find.byKey(const Key('search-channel-row-channel-0')), + findsOneWidget, + ); + expect( + find.byKey(const Key('search-channel-row-channel-199')), + findsNothing, + reason: 'Offscreen results must not be eagerly mounted.', + ); + }); + testWidgets('keeps search results scrollable above the keyboard', ( tester, ) async { @@ -588,12 +637,18 @@ void main() { ); await tester.pumpAndSettle(); - final results = tester.widget( + final results = tester.widget( find.byKey(const Key('search-results-list')), ); - final padding = results.padding! as EdgeInsets; - - expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); + expect(results.slivers, isNotEmpty); + expect( + tester + .widget( + find.byKey(const Key('search-results-bottom-clearance')), + ) + .height, + Grid.xl + footerClearance + keyboardInset, + ); }); testWidgets('keeps no-results feedback above the keyboard', (tester) async {