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 804fab87af..d90b4bc167 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,7 @@ class _MessageList extends HookConsumerWidget { localMessageSendAnimationProvider(channelId), ); final displayEntries = groupMembershipTimelineEntries(entries); - final calendarDays = TimelineCalendarDays(); + final calendarDays = ref.watch(timelineCalendarDaysFactoryProvider)(); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final stickyDayTimestamp = useValueNotifier(null); @@ -976,7 +976,7 @@ class _MessageList extends HookConsumerWidget { startsDay: showDayDivider, endsDay: nextMessage != null && - !isSameDay( + !calendarDays.isSameDay( message.createdAt, nextMessage.createdAt, ), @@ -997,7 +997,7 @@ class _MessageList extends HookConsumerWidget { startsDay: showDayDivider, endsDay: nextMessage != null && - !isSameDay( + !calendarDays.isSameDay( message.createdAt, nextMessage.createdAt, ), diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index b165394ed4..603814c998 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; // Re-export shortPubkey so existing callers continue to compile. @@ -107,6 +108,12 @@ class TimelineCalendarDays { bool isSameDay(int a, int b) => _day(a) == _day(b); } +/// Creates a fresh calendar cache for each timeline build. +final timelineCalendarDaysFactoryProvider = + Provider( + (ref) => TimelineCalendarDays.new, + ); + /// 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 1d2812f6c9..205c091247 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,6 @@ part of '../thread_detail_page.dart'; -class _ThreadMessageList extends HookWidget { +class _ThreadMessageList extends HookConsumerWidget { final double headHeight; final ValueChanged onHeadHeightChanged; final LaidOutViewport viewport; @@ -144,8 +144,8 @@ class _ThreadMessageList extends HookWidget { } @override - Widget build(BuildContext context) { - final calendarDays = TimelineCalendarDays(); + Widget build(BuildContext context, WidgetRef ref) { + final calendarDays = ref.watch(timelineCalendarDaysFactoryProvider)(); final headLink = useMemoized(LayerLink.new); final loadingHeadLink = useMemoized(LayerLink.new); final headScrollPosition = useRef(null); @@ -308,7 +308,7 @@ class _ThreadMessageList extends HookWidget { startsDay: showDayDivider, endsDay: nextReply != null && - !isSameDay( + !calendarDays.isSameDay( reply.createdAt, nextReply.createdAt, ), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 55f02451a2..35edf8bbfb 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -92,6 +92,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/thread_ids.dart'; part 'thread_reply_refresh_cases.dart'; +part 'timeline_calendar_builder_cases.dart'; part 'thread_title_capsule_cases.dart'; part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; @@ -235,6 +236,7 @@ NostrEvent _edit({ Widget _buildTestable({ bool useRealChannelProviders = false, + TimelineCalendarDays Function()? calendarDaysFactory, ProfileNotifier? profileNotifier, String? signingPubkey, bool darkTheme = false, @@ -303,6 +305,10 @@ Widget _buildTestable({ return ProviderScope( retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ + if (calendarDaysFactory != null) + timelineCalendarDaysFactoryProvider.overrideWithValue( + calendarDaysFactory, + ), if (sender != null) sendMessageProvider.overrideWithValue(sender), if (soloSender != null) ...[ meSendMessageProvider.overrideWithValue(soloSender), @@ -13642,134 +13648,7 @@ void main() { expect(observer.pushCount, initialPushCount + 1); }); - testWidgets('thread shows day dividers when replies cross days', ( - tester, - ) async { - final rootCreatedAt = - DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000; - final nextDayCreatedAt = - DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000; - final rootEvent = _textMsg( - id: threadIdThreadDashRoot, - pubkey: 'alice', - content: 'Thread root', - createdAt: rootCreatedAt, - ); - final replies = [ - _textMsg( - id: 'reply-same-day', - pubkey: 'bob', - content: 'Same day', - createdAt: rootCreatedAt + 60, - extraTags: const [ - ['e', threadIdThreadDashRoot, '', 'reply'], - ], - ), - _textMsg( - id: 'reply-next-day', - pubkey: 'bob', - content: 'Next day', - createdAt: nextDayCreatedAt, - extraTags: const [ - ['e', threadIdThreadDashRoot, '', 'reply'], - ], - ), - ]; - - await tester.pumpWidget( - _buildTestable( - messages: [rootEvent], - threadReplies: {threadIdThreadDashRoot: replies}, - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), - }, - ), - ); - await tester.pumpAndSettle(); - - final threadHead = formatTimeline([rootEvent]).single; - Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: threadHead, - allMessages: [threadHead], - channelId: _channelId, - currentPubkey: 'self', - isMember: true, - isArchived: false, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(DayDivider), findsNWidgets(2)); - expect( - find.descendant( - of: find.byType(DayDivider), - matching: find.text(formatDayHeading(rootCreatedAt)), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byType(DayDivider), - matching: find.text(formatDayHeading(nextDayCreatedAt)), - ), - findsOneWidget, - ); - // The list runs top-down (head first), so tail spacing lives on the list - // and reply groups carry none. - final threadList = tester.widget( - find.byKey(const ValueKey('thread-message-list')), - ); - expect(threadList.reverse, isFalse); - final threadComposerDockHeight = tester - .getSize(find.byKey(const ValueKey('thread-composer-dock'))) - .height; - expect(threadList.padding!.bottom, Grid.xs + threadComposerDockHeight); - final newestThreadGroup = tester.widget( - find.byKey(const ValueKey('thread-message-group-reply-next-day')), - ); - expect(newestThreadGroup.padding, EdgeInsets.zero); - - // The head sits above its replies rather than jammed against the - // composer, matching desktop's thread panel. - final headY = tester - .getTopLeft( - find.byKey( - const ValueKey('thread-message-group-$threadIdThreadDashRoot'), - ), - ) - .dy; - final oldestReplyY = tester - .getTopLeft( - find.byKey(const ValueKey('thread-message-group-reply-same-day')), - ) - .dy; - final newestReplyY = tester - .getTopLeft( - find.byKey(const ValueKey('thread-message-group-reply-next-day')), - ) - .dy; - expect(headY, lessThan(oldestReplyY)); - expect(oldestReplyY, lessThan(newestReplyY)); - final threadTimestamp = tester.widget( - find.byKey( - const ValueKey('thread-message-timestamp-$threadIdThreadDashRoot'), - ), - ); - expect(threadTimestamp.style?.fontSize, 11); - expect( - find.descendant( - of: find.byKey( - const ValueKey('thread-message-row-$threadIdThreadDashRoot'), - ), - matching: find.text('·'), - ), - findsNothing, - ); - }); + timelineCalendarBuilderTests(); testWidgets('thread pins and updates the active date while scrolling', ( tester, diff --git a/mobile/test/features/channels/timeline_calendar_builder_cases.dart b/mobile/test/features/channels/timeline_calendar_builder_cases.dart new file mode 100644 index 0000000000..60dfe457a0 --- /dev/null +++ b/mobile/test/features/channels/timeline_calendar_builder_cases.dart @@ -0,0 +1,231 @@ +part of 'channel_detail_page_test.dart'; + +void timelineCalendarBuilderTests() { + testWidgets('timeline builders use cached boundaries for every row ending', ( + tester, + ) async { + final conversions = >[]; + TimelineCalendarDays createCalendar() { + final counts = {}; + conversions.add(counts); + return TimelineCalendarDays.withConverter((timestamp) { + counts.update(timestamp, (count) => count + 1, ifAbsent: () => 1); + // Distinct dates make a stray uncached comparison observable in the + // rendered row spacing even though these timestamps share a real day. + return DateTime(2030, 1, timestamp); + }); + } + + final root = _textMsg( + id: threadIdThreadDashRoot, + pubkey: 'alice', + content: 'Calendar root', + createdAt: 1000, + ); + final replies = [ + for (final timestamp in [1060, 1120]) + _textMsg( + id: 'calendar-reply-$timestamp', + pubkey: 'bob', + content: 'Calendar reply $timestamp', + createdAt: timestamp, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ), + ]; + await tester.pumpWidget( + _buildTestable( + calendarDaysFactory: createCalendar, + messages: [ + root, + _systemMsg( + id: 'calendar-system', + createdAt: 1060, + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'alice', + }, + ), + _textMsg( + id: 'calendar-last', + pubkey: 'bob', + content: 'Last', + createdAt: 1120, + ), + ], + threadReplies: {threadIdThreadDashRoot: replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + void expectEndSpacing(String rowKey) { + final padding = tester.widget( + find + .descendant( + of: find.byKey(ValueKey(rowKey)), + matching: find.byType(Padding), + ) + .first, + ); + expect(padding.padding.resolve(TextDirection.ltr).bottom, 0); + } + + expectEndSpacing('message-row-$threadIdThreadDashRoot'); + expectEndSpacing('system-message-row-calendar-system'); + 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(); + expectEndSpacing('thread-message-row-calendar-reply-1060'); + final usedCaches = conversions.where((counts) => counts.isNotEmpty); + expect(usedCaches, isNotEmpty); + for (final counts in usedCaches) { + expect(counts.values, everyElement(1)); + } + }); + + testWidgets('thread shows day dividers when replies cross days', ( + tester, + ) async { + final rootCreatedAt = + DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final nextDayCreatedAt = + DateTime(2025, 1, 2, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final rootEvent = _textMsg( + id: threadIdThreadDashRoot, + pubkey: 'alice', + content: 'Thread root', + createdAt: rootCreatedAt, + ); + final replies = [ + _textMsg( + id: 'reply-same-day', + pubkey: 'bob', + content: 'Same day', + createdAt: rootCreatedAt + 60, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ), + _textMsg( + id: 'reply-next-day', + pubkey: 'bob', + content: 'Next day', + createdAt: nextDayCreatedAt, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {threadIdThreadDashRoot: replies}, + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DayDivider), findsNWidgets(2)); + expect( + find.descendant( + of: find.byType(DayDivider), + matching: find.text(formatDayHeading(rootCreatedAt)), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(DayDivider), + matching: find.text(formatDayHeading(nextDayCreatedAt)), + ), + findsOneWidget, + ); + // The list runs top-down (head first), so tail spacing lives on the list + // and reply groups carry none. + final threadList = tester.widget( + find.byKey(const ValueKey('thread-message-list')), + ); + expect(threadList.reverse, isFalse); + final threadComposerDockHeight = tester + .getSize(find.byKey(const ValueKey('thread-composer-dock'))) + .height; + expect(threadList.padding!.bottom, Grid.xs + threadComposerDockHeight); + final newestThreadGroup = tester.widget( + find.byKey(const ValueKey('thread-message-group-reply-next-day')), + ); + expect(newestThreadGroup.padding, EdgeInsets.zero); + + // The head sits above its replies rather than jammed against the + // composer, matching desktop's thread panel. + final headY = tester + .getTopLeft( + find.byKey( + const ValueKey('thread-message-group-$threadIdThreadDashRoot'), + ), + ) + .dy; + final oldestReplyY = tester + .getTopLeft( + find.byKey(const ValueKey('thread-message-group-reply-same-day')), + ) + .dy; + final newestReplyY = tester + .getTopLeft( + find.byKey(const ValueKey('thread-message-group-reply-next-day')), + ) + .dy; + expect(headY, lessThan(oldestReplyY)); + expect(oldestReplyY, lessThan(newestReplyY)); + final threadTimestamp = tester.widget( + find.byKey( + const ValueKey('thread-message-timestamp-$threadIdThreadDashRoot'), + ), + ); + expect(threadTimestamp.style?.fontSize, 11); + expect( + find.descendant( + of: find.byKey( + const ValueKey('thread-message-row-$threadIdThreadDashRoot'), + ), + matching: find.text('·'), + ), + findsNothing, + ); + }); +}