mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
Use cached calendar boundaries for every timeline row
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user