perf(mobile): cache calendar conversions within timeline builds

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 12:26:49 -07:00
parent 6286a54321
commit 66ee8e2f8f
4 changed files with 53 additions and 5 deletions

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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