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 5d88781750..adc60fd5d1 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -56,7 +56,6 @@ 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); @@ -122,7 +121,7 @@ class _MessageList extends HookConsumerWidget { : null; final startsDay = previousMessage == null || - !calendarDays.isSameDay(previousMessage.createdAt, message.createdAt); + !isSameDay(previousMessage.createdAt, message.createdAt); final reversedIndex = displayEntries.length - 1 - chronologicalIndex; if (startsDay) { currentDayTimestamp = message.createdAt; @@ -912,10 +911,7 @@ class _MessageList extends HookConsumerWidget { final prevMessage = prevEntry?.message; final showDayDivider = prevMessage == null || - !calendarDays.isSameDay( - prevMessage.createdAt, - message.createdAt, - ); + !isSameDay(prevMessage.createdAt, message.createdAt); final showAuthor = !message.isSystem && @@ -936,10 +932,7 @@ class _MessageList extends HookConsumerWidget { nextMessage == null || nextMessage.isSystem || nextMessage.hasAttachments || - !calendarDays.isSameDay( - message.createdAt, - nextMessage.createdAt, - ) || + !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 60ed7fd573..e67154a753 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -79,23 +79,6 @@ 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 c2f3ab17f0..23024701e7 100644 --- a/mobile/lib/features/channels/local_message_send_transition.dart +++ b/mobile/lib/features/channels/local_message_send_transition.dart @@ -68,39 +68,12 @@ 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 6668b8a8f6..a35409a9e2 100644 --- a/mobile/lib/features/channels/message_footer_layout.dart +++ b/mobile/lib/features/channels/message_footer_layout.dart @@ -54,7 +54,9 @@ class _FooterLayout extends MultiChildRenderObjectWidget { BuildContext context, covariant RenderBox renderObject, ) { - (renderObject as _RenderMessageFooter).overlap = overlap; + (renderObject as _RenderMessageFooter) + ..overlap = overlap + ..markNeedsLayout(); } } @@ -68,15 +70,9 @@ class _RenderMessageFooter extends RenderBox RenderBox, ContainerBoxParentData > { - _RenderMessageFooter(this._overlap); + _RenderMessageFooter(this.overlap); - double _overlap; - double get overlap => _overlap; - set overlap(double value) { - if (_overlap == value) return; - _overlap = value; - markNeedsLayout(); - } + double overlap; @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 8e2e8b07e0..d4894bfe29 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -144,7 +144,6 @@ 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); @@ -238,7 +237,7 @@ class _ThreadMessageList extends HookWidget { ? replies[chronologicalIndex - 1] : null; final previousMessage = previousReply ?? head; - final showDayDivider = !calendarDays.isSameDay( + final showDayDivider = !isSameDay( previousMessage.createdAt, reply.createdAt, ); @@ -259,10 +258,7 @@ class _ThreadMessageList extends HookWidget { reply.hasAttachments || nextReply?.hasAttachments == true || nextReply == null || - !calendarDays.isSameDay( - reply.createdAt, - nextReply.createdAt, - ) || + !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 329a46eab3..f8e5b11441 100644 --- a/mobile/test/features/channels/date_formatters_test.dart +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -5,26 +5,6 @@ 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 3b4d8742f3..dfde7864f6 100644 --- a/mobile/test/features/channels/local_message_send_transition_test.dart +++ b/mobile/test/features/channels/local_message_send_transition_test.dart @@ -64,43 +64,6 @@ 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 { @@ -122,7 +85,7 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(tester.getSize(find.byType(LocalMessageSendTransition)).height, 80); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); expect( tester.getTopLeft(find.byKey(const ValueKey('history'))).dy, @@ -167,10 +130,7 @@ void main() { await tester.pumpWidget( _testable(animate: reduced, reduceMotion: reduced), ); - expect( - tester.getSize(find.byType(LocalMessageSendTransition)).height, - 80, - ); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); expect( tester .widget(find.byType(LocalSendProgress)) @@ -178,10 +138,7 @@ void main() { 1, ); await tester.pump(const Duration(milliseconds: 100)); - expect( - tester.getSize(find.byType(LocalMessageSendTransition)).height, - 80, - ); + expect(tester.getSize(find.byType(LocalSendRow)).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 22f950dd82..39b1cf3bf0 100644 --- a/mobile/test/features/channels/message_footer_layout_test.dart +++ b/mobile/test/features/channels/message_footer_layout_test.dart @@ -1,47 +1,8 @@ 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 { @@ -157,21 +118,3 @@ 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(); - } -}