fix(mobile): lazily lay out new-DM recipients (#8435)

The new-message picker laid out every recipient inside an outer scroll
view. Searching or changing selection could therefore mount the whole
directory in one frame. Use a bounded sliver viewport so offscreen
recipients stay lazy while the search field and selection chips still
scroll above the keyboard.

### Validation

- Current-head CI passed, including all Flutter test shards, Android,
and native/release Swift jobs. Code and security reviews found no
issues. After merging main and retaining both test imports, all 81
channel-page tests and the full mobile check/test suite passed.

- Added a 200-person widget regression: the original mounts all 200
rows; the fix keeps fewer than 30 mounted through open, search,
selection and removal, and can scroll to the final recipient. Covers
no-match and closing too.
- Strengthened the keyboard/chip test to verify the eight-recipient
limit and that removing a chip makes the ninth recipient reachable.
- All 79 channel-page tests passed; the strengthened limit test and
Flutter analysis passed.
- Native iOS simulator build passed. On a separately cloned paired
simulator, verified populated directory/search, eight selected
recipients, limit message, removal, no-match, scrolling to the final
recipient and close without creating a conversation. Debug/AX
instrumentation and a newer Flutter baseline make this functional
validation, not a controlled release-speed benchmark.
- Normal pre-push mobile checks passed, including formatting, analysis
and the full Flutter test suite. Required CI exposed existing formatting
in `message_carousel_preview_cases.dart`; a separate formatter-only
commit corrects it.
- The full local repository gate hit an unrelated Rust failure in
`buzz-acp::signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(exit status `None` rather than `Some(143)`). No Rust code changed. The
gate stopped at unit tests, so later build stages did not run.

### Related issue

No duplicate recipient-layout fix found in the existing issue/PR search.

### Before and after

The synthetic 200-person picker renders identically before and after
(with a reserved keyboard inset). One image represents both; mounted-row
assertions establish the performance mechanism separately.

![Recipient picker before and
after](https://raw.githubusercontent.com/block/buzz/c9980118b75e64ee8ba0b559b7cc981ee59f809b/pr-8435--dm-directory.png)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-20 15:17:52 -07:00
committed by GitHub
parent bf4a163510
commit 93114b4279
3 changed files with 248 additions and 93 deletions

View File

@@ -535,12 +535,14 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
// The sheet bounds this viewport even with the keyboard visible.
// Keep rows as slivers so search and chip updates do not lay out the
// entire directory. The field still scrolls with long chip selections.
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SliverToBoxAdapter(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
child: SizedBox(
@@ -664,24 +666,27 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
),
),
),
const SizedBox(height: Grid.xs),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const SizedBox(
),
const SliverToBoxAdapter(child: SizedBox(height: Grid.xs)),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const SliverToBoxAdapter(
child: SizedBox(
height: 96,
child: Center(
child: Text(
'DMs support up to nine people, including you.',
),
),
);
}
if (directoryAsync.isLoading &&
directoryAsync.asData == null ||
isSearchTransitionPending) {
return const SizedBox(
),
);
}
if (directoryAsync.isLoading && directoryAsync.asData == null ||
isSearchTransitionPending) {
return const SliverToBoxAdapter(
child: SizedBox(
height: 280,
child: Center(
child: BuzzLoadingIndicator(
@@ -689,10 +694,12 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
semanticLabel: 'Loading people',
),
),
);
}
if (directoryAsync.hasError) {
return SizedBox(
),
);
}
if (directoryAsync.hasError) {
return SliverToBoxAdapter(
child: SizedBox(
height: 280,
child: Center(
child: Column(
@@ -719,10 +726,12 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
],
),
),
);
}
if (availableResults.isEmpty) {
return SizedBox(
),
);
}
if (availableResults.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox(
height: 280,
child: Center(
child: Text(
@@ -731,78 +740,78 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
: 'No matching users.',
),
),
);
}
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: availableResults.length,
itemBuilder: (context, index) {
final user = availableResults[index];
return AppListCardItem(
index: index,
itemCount: availableResults.length,
dividerIndent: Grid.xs + 40 + Grid.xs,
child: ListTile(
key: Key('new-dm-person-${user.pubkey}'),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.controlSurface,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
isAgent: user.isAgent,
),
title: Text(
names.labelFor(user.pubkey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
BuzzIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
),
);
},
),
);
},
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
}
return SliverList.builder(
itemCount: availableResults.length,
itemBuilder: (context, index) {
final user = availableResults[index];
return AppListCardItem(
index: index,
itemCount: availableResults.length,
dividerIndent: Grid.xs + 40 + Grid.xs,
child: ListTile(
key: Key('new-dm-person-${user.pubkey}'),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.controlSurface,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
isAgent: user.isAgent,
),
title: Text(
names.labelFor(user.pubkey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
BuzzIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
),
);
},
);
},
),
if (submitError.value case final error?) ...[
const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)),
SliverToBoxAdapter(
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
),
],
),
],
),
),
);