mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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.  --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user