mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
perf(mobile): avoid redundant history-row work during navigation
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -56,6 +56,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
localMessageSendAnimationProvider(channelId),
|
||||
);
|
||||
final displayEntries = groupMembershipTimelineEntries(entries);
|
||||
final calendarDays = TimelineCalendarDays();
|
||||
final itemScrollController = useMemoized(ItemScrollController.new);
|
||||
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
|
||||
final stickyDayTimestamp = useValueNotifier<int?>(null);
|
||||
@@ -121,7 +122,7 @@ class _MessageList extends HookConsumerWidget {
|
||||
: null;
|
||||
final startsDay =
|
||||
previousMessage == null ||
|
||||
!isSameDay(previousMessage.createdAt, message.createdAt);
|
||||
!calendarDays.isSameDay(previousMessage.createdAt, message.createdAt);
|
||||
final reversedIndex = displayEntries.length - 1 - chronologicalIndex;
|
||||
if (startsDay) {
|
||||
currentDayTimestamp = message.createdAt;
|
||||
@@ -911,7 +912,10 @@ class _MessageList extends HookConsumerWidget {
|
||||
final prevMessage = prevEntry?.message;
|
||||
final showDayDivider =
|
||||
prevMessage == null ||
|
||||
!isSameDay(prevMessage.createdAt, message.createdAt);
|
||||
!calendarDays.isSameDay(
|
||||
prevMessage.createdAt,
|
||||
message.createdAt,
|
||||
);
|
||||
|
||||
final showAuthor =
|
||||
!message.isSystem &&
|
||||
@@ -932,7 +936,10 @@ class _MessageList extends HookConsumerWidget {
|
||||
nextMessage == null ||
|
||||
nextMessage.isSystem ||
|
||||
nextMessage.hasAttachments ||
|
||||
!isSameDay(message.createdAt, nextMessage.createdAt) ||
|
||||
!calendarDays.isSameDay(
|
||||
message.createdAt,
|
||||
nextMessage.createdAt,
|
||||
) ||
|
||||
nextMessage.pubkey.toLowerCase() !=
|
||||
message.pubkey.toLowerCase() ||
|
||||
nextMessage.createdAt - message.createdAt > 300;
|
||||
|
||||
@@ -79,6 +79,23 @@ bool isSameDay(int a, int b) {
|
||||
return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day;
|
||||
}
|
||||
|
||||
/// Reuses local calendar components while building one timeline.
|
||||
///
|
||||
/// Adjacent rows compare the same timestamps for dividers, author grouping,
|
||||
/// avatars, and sticky headers. Resolve each timestamp only once. Create a new
|
||||
/// instance on each build so a timezone change cannot leave stale cached days.
|
||||
class TimelineCalendarDays {
|
||||
final _days = <int, (int, int, int)>{};
|
||||
|
||||
(int, int, int) _day(int unixSeconds) => _days.putIfAbsent(unixSeconds, () {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000);
|
||||
return (date.year, date.month, date.day);
|
||||
});
|
||||
|
||||
/// Whether two timestamps fall on the same local calendar day.
|
||||
bool isSameDay(int a, int b) => _day(a) == _day(b);
|
||||
}
|
||||
|
||||
/// Returns a compact relative time string like "just now", "5m ago", "3h ago",
|
||||
/// "2d ago", or a short date for older timestamps.
|
||||
String relativeTime(int unixSeconds) {
|
||||
|
||||
@@ -68,12 +68,39 @@ class LocalMessageSendTransition extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final needsAnimation = useMemoized(
|
||||
() => animate && !reduceMotion,
|
||||
const [],
|
||||
);
|
||||
// Loaded history never needs a ticker, animation builder, or expanding row.
|
||||
// Choose at mount: later claim or accessibility changes must not replay a
|
||||
// settled entrance or remount media, selection, and other message state.
|
||||
if (!needsAnimation) {
|
||||
return LocalSendProgress(progress: 1, widthProgress: 1, child: child);
|
||||
}
|
||||
return _AnimatedLocalMessageSendTransition(transition: this);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLocalMessageSendTransition extends HookConsumerWidget {
|
||||
const _AnimatedLocalMessageSendTransition({required this.transition});
|
||||
|
||||
final LocalMessageSendTransition transition;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final animate = transition.animate;
|
||||
final channelId = transition.channelId;
|
||||
final messageId = transition.messageId;
|
||||
final startOffsetFactor = transition.startOffsetFactor;
|
||||
final child = transition.child;
|
||||
final origin = useMemoized(
|
||||
() => !animate || channelId == null || messageId == null
|
||||
? null
|
||||
: ref
|
||||
.read(localMessageSendAnimationProvider(channelId!).notifier)
|
||||
.originFor(messageId!),
|
||||
.read(localMessageSendAnimationProvider(channelId).notifier)
|
||||
.originFor(messageId),
|
||||
const [],
|
||||
);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
@@ -54,9 +54,7 @@ class _FooterLayout extends MultiChildRenderObjectWidget {
|
||||
BuildContext context,
|
||||
covariant RenderBox renderObject,
|
||||
) {
|
||||
(renderObject as _RenderMessageFooter)
|
||||
..overlap = overlap
|
||||
..markNeedsLayout();
|
||||
(renderObject as _RenderMessageFooter).overlap = overlap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +68,15 @@ class _RenderMessageFooter extends RenderBox
|
||||
RenderBox,
|
||||
ContainerBoxParentData<RenderBox>
|
||||
> {
|
||||
_RenderMessageFooter(this.overlap);
|
||||
_RenderMessageFooter(this._overlap);
|
||||
|
||||
double overlap;
|
||||
double _overlap;
|
||||
double get overlap => _overlap;
|
||||
set overlap(double value) {
|
||||
if (_overlap == value) return;
|
||||
_overlap = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
|
||||
@@ -144,6 +144,7 @@ class _ThreadMessageList extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final calendarDays = TimelineCalendarDays();
|
||||
final headLink = useMemoized(LayerLink.new);
|
||||
final loadingHeadLink = useMemoized(LayerLink.new);
|
||||
final headScrollPosition = useRef<ScrollPosition?>(null);
|
||||
@@ -237,7 +238,7 @@ class _ThreadMessageList extends HookWidget {
|
||||
? replies[chronologicalIndex - 1]
|
||||
: null;
|
||||
final previousMessage = previousReply ?? head;
|
||||
final showDayDivider = !isSameDay(
|
||||
final showDayDivider = !calendarDays.isSameDay(
|
||||
previousMessage.createdAt,
|
||||
reply.createdAt,
|
||||
);
|
||||
@@ -258,7 +259,10 @@ class _ThreadMessageList extends HookWidget {
|
||||
reply.hasAttachments ||
|
||||
nextReply?.hasAttachments == true ||
|
||||
nextReply == null ||
|
||||
!isSameDay(reply.createdAt, nextReply.createdAt) ||
|
||||
!calendarDays.isSameDay(
|
||||
reply.createdAt,
|
||||
nextReply.createdAt,
|
||||
) ||
|
||||
nextReply.pubkey.toLowerCase() !=
|
||||
reply.pubkey.toLowerCase() ||
|
||||
nextReply.createdAt - reply.createdAt > 300;
|
||||
|
||||
@@ -5,6 +5,26 @@ import 'package:buzz/features/channels/date_formatters.dart';
|
||||
int _ts(DateTime local) => local.millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
void main() {
|
||||
test('timeline calendar grouping matches uncached local-day comparisons', () {
|
||||
final days = TimelineCalendarDays();
|
||||
final timestamps = [
|
||||
DateTime(2025, 12, 31, 23, 59),
|
||||
DateTime(2026, 1, 1),
|
||||
DateTime(2026, 3, 8, 1, 30),
|
||||
DateTime(2026, 3, 8, 3, 30),
|
||||
DateTime(2026, 11, 1, 1, 30),
|
||||
DateTime(2026, 11, 1, 3, 30),
|
||||
DateTime(2026, 11, 2),
|
||||
].map(_ts).toList();
|
||||
for (var repeat = 0; repeat < 3; repeat++) {
|
||||
for (final a in timestamps) {
|
||||
for (final b in timestamps) {
|
||||
expect(days.isSameDay(a, b), isSameDay(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
group('formatDayHeading', () {
|
||||
// Fix "now" so tests are deterministic.
|
||||
final now = DateTime(2026, 4, 23, 14, 30); // Apr 23 2026, 2:30 PM local
|
||||
|
||||
@@ -64,6 +64,43 @@ void main() {
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
testWidgets('loaded history has no send animation or row layout', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_testable(animate: false));
|
||||
final history = find.byType(LocalMessageSendTransition);
|
||||
expect(
|
||||
find.descendant(of: history, matching: find.byType(AnimatedBuilder)),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byType(LocalSendRow), findsNothing);
|
||||
expect(tester.getSize(history).height, 80);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('avatar'))),
|
||||
const Size(40, 40),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('enabling motion does not replay a settled entrance', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_testable(animate: true, reduceMotion: true));
|
||||
final avatar = tester.element(find.byKey(const ValueKey('avatar')));
|
||||
await tester.pumpWidget(_testable(animate: true, reduceMotion: false));
|
||||
expect(tester.element(find.byKey(const ValueKey('avatar'))), same(avatar));
|
||||
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
|
||||
expect(find.byType(LocalSendRow), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('expiring a send claim retains message state', (tester) async {
|
||||
await tester.pumpWidget(_testable());
|
||||
await tester.pumpAndSettle();
|
||||
final avatar = tester.element(find.byKey(const ValueKey('avatar')));
|
||||
await tester.pumpWidget(_testable(animate: false));
|
||||
expect(tester.element(find.byKey(const ValueKey('avatar'))), same(avatar));
|
||||
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
|
||||
});
|
||||
|
||||
testWidgets('history moves up while the avatar remains anchored', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -85,7 +122,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
|
||||
expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80);
|
||||
expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('history'))).dy,
|
||||
@@ -130,7 +167,10 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
_testable(animate: reduced, reduceMotion: reduced),
|
||||
);
|
||||
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
|
||||
expect(
|
||||
tester.getSize(find.byType(LocalMessageSendTransition)).height,
|
||||
80,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<LocalSendProgress>(find.byType(LocalSendProgress))
|
||||
@@ -138,7 +178,10 @@ void main() {
|
||||
1,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(tester.getSize(find.byType(LocalSendRow)).height, 80);
|
||||
expect(
|
||||
tester.getSize(find.byType(LocalMessageSendTransition)).height,
|
||||
80,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import 'package:buzz/features/channels/message_footer_layout.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('unchanged footer geometry does not relayout its timeline row', (
|
||||
tester,
|
||||
) async {
|
||||
var layouts = 0;
|
||||
final overlap = ValueNotifier(8.0);
|
||||
addTearDown(overlap.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _LayoutCounter(
|
||||
onLayout: () => layouts++,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: overlap,
|
||||
builder: (context, value, child) => MessageFooterLayout(
|
||||
overlap: value,
|
||||
footer: const SizedBox(width: 100, height: 32),
|
||||
child: const SizedBox(width: 200, height: 60),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final initialLayouts = layouts;
|
||||
tester.element(find.byType(MessageFooterLayout)).markNeedsBuild();
|
||||
await tester.pump();
|
||||
expect(
|
||||
layouts,
|
||||
initialLayouts,
|
||||
reason: 'Rebuilding unchanged reactions must not dirty the row layout',
|
||||
);
|
||||
overlap.value = 12;
|
||||
await tester.pump();
|
||||
expect(layouts, greaterThan(initialLayouts));
|
||||
expect(tester.getSize(find.byType(MessageFooterLayout)).height, 80);
|
||||
});
|
||||
|
||||
testWidgets('footer global transforms and semantics include overlap offset', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -118,3 +157,21 @@ void main() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _LayoutCounter extends SingleChildRenderObjectWidget {
|
||||
const _LayoutCounter({required this.onLayout, required super.child});
|
||||
final VoidCallback onLayout;
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) =>
|
||||
_CountLayouts(onLayout);
|
||||
}
|
||||
|
||||
class _CountLayouts extends RenderProxyBox {
|
||||
_CountLayouts(this.onLayout);
|
||||
final VoidCallback onLayout;
|
||||
@override
|
||||
void performLayout() {
|
||||
onLayout();
|
||||
super.performLayout();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user