From 66ee8e2f8fc2c2709c5097d1025a3f859af2ac76 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 12:26:49 -0700 Subject: [PATCH] perf(mobile): cache calendar conversions within timeline builds Signed-off-by: Tom Brow --- .../channel_detail_page/message_list.dart | 13 +++++++++--- .../features/channels/date_formatters.dart | 17 ++++++++++++++++ .../thread_detail_page/message_list.dart | 8 ++++++-- .../channels/date_formatters_test.dart | 20 +++++++++++++++++++ 4 files changed, 53 insertions(+), 5 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/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