Use cached calendar boundaries for every timeline row

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 13:21:32 -07:00
committed by Tom Brow
parent 724c435aa8
commit ad4b674b16
5 changed files with 252 additions and 135 deletions

View File

@@ -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<int?>(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,
),

View File

@@ -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<TimelineCalendarDays Function()>(
(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) {

View File

@@ -1,6 +1,6 @@
part of '../thread_detail_page.dart';
class _ThreadMessageList extends HookWidget {
class _ThreadMessageList extends HookConsumerWidget {
final double headHeight;
final ValueChanged<double> 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<ScrollPosition?>(null);
@@ -308,7 +308,7 @@ class _ThreadMessageList extends HookWidget {
startsDay: showDayDivider,
endsDay:
nextReply != null &&
!isSameDay(
!calendarDays.isSameDay(
reply.createdAt,
nextReply.createdAt,
),

View File

@@ -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<void>(
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<ScrollablePositionedList>(
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<Padding>(
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<Text>(
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,

View File

@@ -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 = <Map<int, int>>[];
TimelineCalendarDays createCalendar() {
final counts = <int, int>{};
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<Padding>(
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<void>(
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<void>(
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<ScrollablePositionedList>(
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<Padding>(
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<Text>(
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,
);
});
}