mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user