Make remaining mobile result lists lazy

Convert search result sections and forum replies to lazy slivers, and add
regression coverage proving distant rows are not mounted offscreen.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-13 16:58:27 -06:00
co-authored by Carl
parent b796e0e1ae
commit 1fc18a0fef
4 changed files with 280 additions and 156 deletions
@@ -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)),
],
),
),
+134 -113
View File
@@ -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<void>(
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<void>(
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<void>(
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<void>(
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,
);
},
),
],
);
}
@@ -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 {
@@ -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<ListView>(
final results = tester.widget<CustomScrollView>(
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<SizedBox>(
find.byKey(const Key('search-results-bottom-clearance')),
)
.height,
Grid.xl + footerClearance + keyboardInset,
);
});
testWidgets('keeps no-results feedback above the keyboard', (tester) async {