From 85f14c4e1e050a2bdd90531fecc4ea053018f818 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 13:34:18 -0700 Subject: [PATCH] Share thread calendar cache with sticky date index Signed-off-by: Tom Brow --- .../features/channels/thread_detail_page.dart | 3 + .../thread_detail_page/message_list.dart | 7 +- .../thread_detail_page/sticky_date.dart | 6 +- .../timeline_calendar_builder_cases.dart | 69 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index b13fa8447e..a533424485 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -313,7 +313,9 @@ class ThreadDetailPage extends HookConsumerWidget { const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; final tailAnchorIndex = replies.length + 1; + final calendarDays = ref.watch(timelineCalendarDaysFactoryProvider)(); final stickyDateIndex = _ThreadStickyDateIndex( + calendarDays: calendarDays, head: liveHead, replies: replies, ); @@ -923,6 +925,7 @@ class ThreadDetailPage extends HookConsumerWidget { children: [ Expanded( child: _ThreadMessageList( + calendarDays: calendarDays, headHeight: headHeight.value ?? 0, onHeadHeightChanged: (height) { if (context.mounted) headHeight.value = height; 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 205c091247..2d818dbbdc 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -1,6 +1,7 @@ part of '../thread_detail_page.dart'; -class _ThreadMessageList extends HookConsumerWidget { +class _ThreadMessageList extends HookWidget { + final TimelineCalendarDays calendarDays; final double headHeight; final ValueChanged onHeadHeightChanged; final LaidOutViewport viewport; @@ -30,6 +31,7 @@ class _ThreadMessageList extends HookConsumerWidget { final Map> childrenByParent; const _ThreadMessageList({ + required this.calendarDays, required this.headHeight, required this.onHeadHeightChanged, required this.viewport, @@ -144,8 +146,7 @@ class _ThreadMessageList extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { - final calendarDays = ref.watch(timelineCalendarDaysFactoryProvider)(); + Widget build(BuildContext context) { final headLink = useMemoized(LayerLink.new); final loadingHeadLink = useMemoized(LayerLink.new); final headScrollPosition = useRef(null); diff --git a/mobile/lib/features/channels/thread_detail_page/sticky_date.dart b/mobile/lib/features/channels/thread_detail_page/sticky_date.dart index 4ac8cd0112..eee0c83ab5 100644 --- a/mobile/lib/features/channels/thread_detail_page/sticky_date.dart +++ b/mobile/lib/features/channels/thread_detail_page/sticky_date.dart @@ -22,6 +22,7 @@ class _ThreadStickyDateIndex { final int messageCount; _ThreadStickyDateIndex({ + required TimelineCalendarDays calendarDays, required TimelineMessage head, required List replies, }) : messageCount = replies.length + 1 { @@ -32,7 +33,10 @@ class _ThreadStickyDateIndex { final message = messages[index]; final startsDay = index == 0 || - !isSameDay(messages[index - 1].createdAt, message.createdAt); + !calendarDays.isSameDay( + messages[index - 1].createdAt, + message.createdAt, + ); if (startsDay) { activeDayTimestamp = message.createdAt; activeDayStartIndex = index; diff --git a/mobile/test/features/channels/timeline_calendar_builder_cases.dart b/mobile/test/features/channels/timeline_calendar_builder_cases.dart index 60dfe457a0..8d561bbf7a 100644 --- a/mobile/test/features/channels/timeline_calendar_builder_cases.dart +++ b/mobile/test/features/channels/timeline_calendar_builder_cases.dart @@ -100,6 +100,75 @@ void timelineCalendarBuilderTests() { } }); + testWidgets('thread sticky index and visible rows share all conversions', ( + tester, + ) async { + final conversions = >[]; + final root = _textMsg( + id: threadIdThreadDashRoot, + pubkey: 'alice', + content: 'Root', + createdAt: 1000, + ); + final replies = [ + for (var i = 1; i <= 60; i++) + _textMsg( + id: 'calendar-long-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1000 + i * 60, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ), + ]; + await tester.pumpWidget( + _buildTestable( + messages: [root], + threadReplies: {threadIdThreadDashRoot: replies}, + calendarDaysFactory: () { + final counts = {}; + conversions.add(counts); + return TimelineCalendarDays.withConverter((timestamp) { + counts.update(timestamp, (count) => count + 1, ifAbsent: () => 1); + return DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + }); + }, + ), + ); + await tester.pumpAndSettle(); + // Discard channel caches before mounting the thread. + conversions.clear(); + final head = formatTimeline([root]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: head, + allMessages: [head], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final threadCaches = conversions + .where((counts) => counts.containsKey(replies.last.createdAt)) + .toList(); + expect(threadCaches, isNotEmpty); + for (final counts in threadCaches) { + expect( + counts.keys, + containsAll([ + root.createdAt, + ...replies.map((reply) => reply.createdAt), + ]), + ); + expect(counts.values, everyElement(1)); + } + }); + testWidgets('thread shows day dividers when replies cross days', ( tester, ) async {