From 773020abaee76caa8a2cd73ec3faacee263f86a7 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sat, 17 Oct 2026 16:51:44 -0700 Subject: [PATCH] perf(mobile): avoid redundant history-row work during navigation Signed-off-by: Tom Brow --- .../channel_detail_page/message_list.dart | 13 ++++- .../features/channels/date_formatters.dart | 17 ++++++ .../local_message_send_transition.dart | 31 +++++++++- .../channels/message_footer_layout.dart | 14 +++-- .../thread_detail_page/message_list.dart | 8 ++- .../channels/date_formatters_test.dart | 20 +++++++ .../local_message_send_transition_test.dart | 49 +++++++++++++++- .../channels/message_footer_layout_test.dart | 57 +++++++++++++++++++ 8 files changed, 194 insertions(+), 15 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index adc60fd5d1..5d88781750 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -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(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; diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index e67154a753..60ed7fd573 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -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) _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) { diff --git a/mobile/lib/features/channels/local_message_send_transition.dart b/mobile/lib/features/channels/local_message_send_transition.dart index 23024701e7..c2f3ab17f0 100644 --- a/mobile/lib/features/channels/local_message_send_transition.dart +++ b/mobile/lib/features/channels/local_message_send_transition.dart @@ -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); diff --git a/mobile/lib/features/channels/message_footer_layout.dart b/mobile/lib/features/channels/message_footer_layout.dart index a35409a9e2..6668b8a8f6 100644 --- a/mobile/lib/features/channels/message_footer_layout.dart +++ b/mobile/lib/features/channels/message_footer_layout.dart @@ -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 > { - _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) { diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index d4894bfe29..8e2e8b07e0 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -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(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; diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart index f8e5b11441..329a46eab3 100644 --- a/mobile/test/features/channels/date_formatters_test.dart +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -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 diff --git a/mobile/test/features/channels/local_message_send_transition_test.dart b/mobile/test/features/channels/local_message_send_transition_test.dart index dfde7864f6..3b4d8742f3 100644 --- a/mobile/test/features/channels/local_message_send_transition_test.dart +++ b/mobile/test/features/channels/local_message_send_transition_test.dart @@ -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(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, + ); }); } } diff --git a/mobile/test/features/channels/message_footer_layout_test.dart b/mobile/test/features/channels/message_footer_layout_test.dart index 39b1cf3bf0..22f950dd82 100644 --- a/mobile/test/features/channels/message_footer_layout_test.dart +++ b/mobile/test/features/channels/message_footer_layout_test.dart @@ -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(); + } +}