mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add channel scroll navigation (#4239)
**Category:** improvement **User Impact:** Mobile readers can jump directly to their oldest unread message and return to the latest message with compact directional controls. **Problem:** Opening an active channel at its newest message makes it easy to miss where unread conversation began, while moving back through history lacks a lightweight route to the live edge. **Solution:** Capture the channel's unread boundary when it opens, offer an accessible up-chevron beneath the app bar to reach that stable target, then reveal the inverse down-chevron at the bottom whenever the reader is away from latest. Deep links retain precedence, and live-follow, pagination, composer resizing, and explicit scroll ownership continue to use the existing timeline behavior. <details> <summary>File changes</summary> **mobile/lib/features/channels/channel_detail_page.dart** Captures the channel's read state at open time and passes a stable unread snapshot into the timeline before the normal deferred read update advances it. **mobile/lib/features/channels/channel_detail_page/message_list.dart** Adds mutually exclusive oldest-unread and latest navigation, with accessible icon controls positioned at opposite edges of the message surface while preserving existing follow and deep-link behavior. **mobile/test/features/channels/channel_detail_page_test.dart** Covers the unread target, compact inverse controls, accessible tooltips, and placement beneath the frosted app bar. </details> ## Reproduction steps 1. Open a Flutter mobile channel that has unread messages without entering through a message or thread deep link. 2. Confirm an up-chevron appears directly below the channel app bar while the timeline remains at latest. 3. Tap the up-chevron and confirm the timeline scrolls to the oldest message that was unread when the channel opened. 4. Confirm the unread control is replaced by a down-chevron at the bottom of the timeline. 5. Tap the down-chevron and confirm the timeline returns to latest and resumes following new messages. ## Screenshots | At latest — up-chevron to oldest unread | Away from latest — down-chevron to latest | |---|---| |  |  | _Real iPhone 17 Pro Simulator captures from the neutral `buzz-mobile-scroll-to` channel._ Originating Buzz thread: `buzz://message?channel=5b16c478-22d8-4ddd-951a-6036e19b81ff&id=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741&thread=6a78af32d7ac6f531b182c4e70dd5a04c503a2dab2ce2c0c74b2c6baa5921741` --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
b29c8cdaa4
commit
d5da74e4e0
@@ -34,6 +34,7 @@ import 'channel_messages_provider.dart';
|
||||
import 'channel_typing_provider.dart';
|
||||
import 'channel_typing_indicator.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'unread_badge/observed_unread_event.dart';
|
||||
import 'compose_bar.dart';
|
||||
import 'composer_dock_size_reporter.dart';
|
||||
import 'date_formatters.dart';
|
||||
@@ -44,6 +45,7 @@ import 'members_sheet.dart';
|
||||
import 'message_actions.dart';
|
||||
import 'message_content.dart';
|
||||
import 'read_state/deferred_read_state_update.dart';
|
||||
import 'read_state/read_state_format.dart';
|
||||
import 'read_state/read_state_provider.dart';
|
||||
import 'read_state/read_state_time.dart';
|
||||
import 'reaction_row.dart';
|
||||
@@ -134,6 +136,52 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
final messagesState = ref.watch(channelMessagesProvider(channel.id));
|
||||
final sessionStatus = ref.watch(relaySessionProvider).status;
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final channelsNotifier = ref.read(channelsProvider.notifier);
|
||||
final initialOrdinaryUnreadMessageIdsRef = useRef<Set<String>>(const {});
|
||||
final initialOldestOrdinaryUnreadMessageIdRef = useRef<String?>(null);
|
||||
final initialForcedUnreadMessageIdsRef = useRef<Set<String>>(const {});
|
||||
final didCaptureInitialReadAt = useRef(false);
|
||||
if (readState.isReady && !didCaptureInitialReadAt.value) {
|
||||
final channelReadAt = readState.effectiveTimestamp(channel.id);
|
||||
final ordinaryUnreadEvents = [
|
||||
for (final event
|
||||
in channelsNotifier
|
||||
.observedUnreadEventsByChannel[channel.id]
|
||||
?.values ??
|
||||
const <ObservedUnreadEvent>[])
|
||||
if (event.rootId == null &&
|
||||
event.createdAt >
|
||||
(observedUnreadEventReadAt(
|
||||
event,
|
||||
channelReadAt,
|
||||
(rootId) => readState.effectiveTimestamp(
|
||||
threadContextKey(rootId),
|
||||
),
|
||||
(messageId) => readState.effectiveTimestamp(
|
||||
msgContextKey(messageId),
|
||||
),
|
||||
) ??
|
||||
0))
|
||||
event,
|
||||
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
initialOrdinaryUnreadMessageIdsRef.value = {
|
||||
for (final event in ordinaryUnreadEvents) event.id,
|
||||
};
|
||||
initialOldestOrdinaryUnreadMessageIdRef.value =
|
||||
ordinaryUnreadEvents.firstOrNull?.id;
|
||||
initialForcedUnreadMessageIdsRef.value = {
|
||||
for (final entry in readState.forcedUnreadContexts.entries)
|
||||
if (entry.value == channel.id && entry.key.startsWith('msg:'))
|
||||
entry.key.substring('msg:'.length),
|
||||
};
|
||||
didCaptureInitialReadAt.value = true;
|
||||
}
|
||||
final initialOrdinaryUnreadMessageIds =
|
||||
initialOrdinaryUnreadMessageIdsRef.value;
|
||||
final initialOldestOrdinaryUnreadMessageId =
|
||||
initialOldestOrdinaryUnreadMessageIdRef.value;
|
||||
final initialForcedUnreadMessageIds =
|
||||
initialForcedUnreadMessageIdsRef.value;
|
||||
final currentPubkey = ref
|
||||
.watch(profileProvider)
|
||||
.whenData((value) => value?.pubkey)
|
||||
@@ -207,14 +255,28 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [channel.id]);
|
||||
|
||||
useEffect(() {
|
||||
final messageId = initialMessageId;
|
||||
if (messageId == null || channel.isForum) return null;
|
||||
final eventIds = {messageId, ?initialThreadRootId};
|
||||
final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
|
||||
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
|
||||
return () => notifier.releaseDeepLinkEvents(eventIds);
|
||||
}, [channel.id, initialMessageId, initialThreadRootId]);
|
||||
useEffect(
|
||||
() {
|
||||
if (channel.isForum) return null;
|
||||
final eventIds = {
|
||||
?initialMessageId,
|
||||
?initialThreadRootId,
|
||||
?initialOldestOrdinaryUnreadMessageId,
|
||||
...initialForcedUnreadMessageIds,
|
||||
};
|
||||
if (eventIds.isEmpty) return null;
|
||||
final notifier = ref.read(channelMessagesProvider(channel.id).notifier);
|
||||
unawaited(_loadDeepLinkEvents(ref, channel.id, eventIds));
|
||||
return () => notifier.releaseDeepLinkEvents(eventIds);
|
||||
},
|
||||
[
|
||||
channel.id,
|
||||
initialMessageId,
|
||||
initialThreadRootId,
|
||||
initialOldestOrdinaryUnreadMessageId,
|
||||
initialForcedUnreadMessageIds,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
if (!readState.isReady || readTimestamp == null) {
|
||||
@@ -378,6 +440,19 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
allMessages: messages,
|
||||
initialMessageId: initialMessageId,
|
||||
initialThreadRootId: initialThreadRootId,
|
||||
initialOrdinaryUnreadMessageIds:
|
||||
initialOrdinaryUnreadMessageIds,
|
||||
initialOldestOrdinaryUnreadMessageId:
|
||||
initialOldestOrdinaryUnreadMessageId,
|
||||
initialForcedUnreadMessageIds:
|
||||
initialForcedUnreadMessageIds,
|
||||
hasInitialUnread:
|
||||
readState.isReady &&
|
||||
(readState.isForcedUnread(channel.id) ||
|
||||
initialForcedUnreadMessageIds
|
||||
.isNotEmpty ||
|
||||
initialOldestOrdinaryUnreadMessageId !=
|
||||
null),
|
||||
channelId: channel.id,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: resolvedChannel.isMember,
|
||||
|
||||
@@ -5,6 +5,10 @@ class _MessageList extends HookConsumerWidget {
|
||||
final List<TimelineMessage> allMessages;
|
||||
final String? initialMessageId;
|
||||
final String? initialThreadRootId;
|
||||
final Set<String> initialOrdinaryUnreadMessageIds;
|
||||
final String? initialOldestOrdinaryUnreadMessageId;
|
||||
final Set<String> initialForcedUnreadMessageIds;
|
||||
final bool hasInitialUnread;
|
||||
final String channelId;
|
||||
final String? currentPubkey;
|
||||
final bool isMember;
|
||||
@@ -17,6 +21,10 @@ class _MessageList extends HookConsumerWidget {
|
||||
required this.allMessages,
|
||||
required this.initialMessageId,
|
||||
required this.initialThreadRootId,
|
||||
required this.initialOrdinaryUnreadMessageIds,
|
||||
required this.initialOldestOrdinaryUnreadMessageId,
|
||||
required this.initialForcedUnreadMessageIds,
|
||||
required this.hasInitialUnread,
|
||||
required this.channelId,
|
||||
required this.currentPubkey,
|
||||
required this.isMember,
|
||||
@@ -42,6 +50,100 @@ class _MessageList extends HookConsumerWidget {
|
||||
final previousLatestEntryId = useRef<String?>(null);
|
||||
final didOpenInitialThread = useRef(false);
|
||||
final didJumpToInitialMessage = useRef(false);
|
||||
final isUnreadNavigationDismissed = useState(false);
|
||||
final detachedWhileUnreadShown = useRef(false);
|
||||
final oldestUnreadMessageId = useState<String?>(null);
|
||||
final unreadBoundaryLoadFailed = useState(false);
|
||||
final unreadBoundaryFetchCount = useRef(0);
|
||||
final hasUnreadDeepLink =
|
||||
initialMessageId != null || initialThreadRootId != null;
|
||||
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (!hasInitialUnread ||
|
||||
hasUnreadDeepLink ||
|
||||
oldestUnreadMessageId.value != null ||
|
||||
unreadBoundaryLoadFailed.value ||
|
||||
entries.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hasLoadedOrdinaryTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null &&
|
||||
entries.any(
|
||||
(entry) =>
|
||||
entry.message.id == initialOldestOrdinaryUnreadMessageId,
|
||||
);
|
||||
final hasLoadedForcedTarget = entries.any(
|
||||
(entry) => initialForcedUnreadMessageIds.contains(entry.message.id),
|
||||
);
|
||||
final hasKnownTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null ||
|
||||
initialForcedUnreadMessageIds.isNotEmpty;
|
||||
final hasLoadedFetchTarget =
|
||||
initialOldestOrdinaryUnreadMessageId != null
|
||||
? hasLoadedOrdinaryTarget
|
||||
: hasLoadedForcedTarget;
|
||||
final canFetchTarget =
|
||||
hasKnownTarget &&
|
||||
!hasLoadedFetchTarget &&
|
||||
!notifier.reachedOldest &&
|
||||
unreadBoundaryFetchCount.value < 4;
|
||||
if (canFetchTarget) {
|
||||
unreadBoundaryFetchCount.value += 1;
|
||||
var cancelled = false;
|
||||
unawaited(
|
||||
Future<void>(() async {
|
||||
final loaded = await notifier.fetchOlder();
|
||||
if (!cancelled && !loaded && !notifier.reachedOldest) {
|
||||
unreadBoundaryLoadFailed.value = true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return () => cancelled = true;
|
||||
}
|
||||
|
||||
if (hasKnownTarget &&
|
||||
!hasLoadedFetchTarget &&
|
||||
!notifier.reachedOldest) {
|
||||
unreadBoundaryLoadFailed.value = true;
|
||||
}
|
||||
|
||||
final ordinaryUnread = entries
|
||||
.where(
|
||||
(entry) =>
|
||||
initialOrdinaryUnreadMessageIds.contains(entry.message.id),
|
||||
)
|
||||
.map((entry) => entry.message)
|
||||
.firstOrNull;
|
||||
final forcedUnread = entries
|
||||
.where(
|
||||
(entry) =>
|
||||
initialForcedUnreadMessageIds.contains(entry.message.id),
|
||||
)
|
||||
.map((entry) => entry.message)
|
||||
.firstOrNull;
|
||||
final candidates = [ordinaryUnread, forcedUnread].nonNulls.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
oldestUnreadMessageId.value = candidates.firstOrNull?.id;
|
||||
return null;
|
||||
},
|
||||
[
|
||||
hasInitialUnread,
|
||||
hasUnreadDeepLink,
|
||||
initialOrdinaryUnreadMessageIds,
|
||||
initialOldestOrdinaryUnreadMessageId,
|
||||
initialForcedUnreadMessageIds,
|
||||
entries.length,
|
||||
notifier.reachedOldest,
|
||||
unreadBoundaryLoadFailed.value,
|
||||
],
|
||||
);
|
||||
|
||||
final showUnreadNavigation =
|
||||
!isUnreadNavigationDismissed.value &&
|
||||
oldestUnreadMessageId.value != null;
|
||||
|
||||
int? reversedIndexOf(String? messageId) {
|
||||
if (messageId == null) return null;
|
||||
@@ -72,6 +174,30 @@ class _MessageList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scrollToOldestUnread() async {
|
||||
final targetIndex = reversedIndexOf(oldestUnreadMessageId.value);
|
||||
if (targetIndex == null ||
|
||||
!itemScrollController.isAttached ||
|
||||
isAutoScrolling.value) {
|
||||
return;
|
||||
}
|
||||
isUnreadNavigationDismissed.value = true;
|
||||
followsLatest.value = false;
|
||||
hasUserScrolled.value = false;
|
||||
isAtLatest.value = false;
|
||||
isAutoScrolling.value = true;
|
||||
try {
|
||||
await itemScrollController.scrollTo(
|
||||
index: targetIndex,
|
||||
alignment: 0.35,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} finally {
|
||||
isAutoScrolling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void scheduleAutoScrollToLatest() {
|
||||
if (autoScrollScheduled.value || isAutoScrolling.value) return;
|
||||
autoScrollScheduled.value = true;
|
||||
@@ -99,6 +225,11 @@ class _MessageList extends HookConsumerWidget {
|
||||
final positions = itemPositionsListener.itemPositions.value;
|
||||
if (positions.isEmpty) return;
|
||||
final nextIsAtLatest = latestIsAtBoundary();
|
||||
if (showUnreadNavigation &&
|
||||
nextIsAtLatest &&
|
||||
detachedWhileUnreadShown.value) {
|
||||
isUnreadNavigationDismissed.value = true;
|
||||
}
|
||||
if (nextIsAtLatest) {
|
||||
if (!isAtLatest.value) isAtLatest.value = true;
|
||||
} else if (followsLatest.value && !hasUserScrolled.value) {
|
||||
@@ -237,6 +368,9 @@ class _MessageList extends HookConsumerWidget {
|
||||
notification.direction != ScrollDirection.idle) {
|
||||
hasUserScrolled.value = true;
|
||||
followsLatest.value = false;
|
||||
if (showUnreadNavigation) {
|
||||
detachedWhileUnreadShown.value = true;
|
||||
}
|
||||
} else if (notification is ScrollEndNotification &&
|
||||
hasUserScrolled.value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -354,7 +488,30 @@ class _MessageList extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isAtLatest.value)
|
||||
if (showUnreadNavigation)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top:
|
||||
frostedAppBarHeight(
|
||||
context,
|
||||
titleContentHeight: appBarTitleContentHeight,
|
||||
) +
|
||||
Grid.xs,
|
||||
child: Center(
|
||||
child: IconButton.filled(
|
||||
key: const ValueKey('channel-jump-to-oldest-unread'),
|
||||
onPressed: scrollToOldestUnread,
|
||||
tooltip: 'Jump to oldest unread message',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
foregroundColor: context.colors.onPrimaryContainer,
|
||||
),
|
||||
icon: const Icon(LucideIcons.chevronUp, size: 20),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!isAtLatest.value)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
||||
@@ -22,6 +22,7 @@ import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
|
||||
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
|
||||
import 'package:buzz/features/channels/small_avatar.dart';
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_cache_provider.dart';
|
||||
@@ -29,6 +30,7 @@ import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -1182,6 +1184,580 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('jumps to the oldest unread with compact inverse controls', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'msg21',
|
||||
createdAt: 1021,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final unreadButton = find.byKey(
|
||||
const ValueKey('channel-jump-to-oldest-unread'),
|
||||
);
|
||||
expect(unreadButton, findsOneWidget);
|
||||
expect(find.byTooltip('Jump to oldest unread message'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.chevronUp), findsOneWidget);
|
||||
expect(tester.getSize(unreadButton), const Size.square(48));
|
||||
final unreadRect = tester.getRect(unreadButton);
|
||||
expect(
|
||||
unreadRect.top,
|
||||
frostedAppBarHeight(tester.element(unreadButton)) + Grid.xs,
|
||||
);
|
||||
expect(find.text('Latest'), findsNothing);
|
||||
|
||||
await tester.tap(unreadButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Message 21'), findsOneWidget);
|
||||
expect(unreadButton, findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-latest')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Latest'), findsOneWidget);
|
||||
expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('loads history through the oldest unread boundary', (
|
||||
tester,
|
||||
) async {
|
||||
final newestPage = [
|
||||
for (var i = 50; i < 100; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final olderPage = [
|
||||
for (var i = 0; i < 50; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(
|
||||
newestPage,
|
||||
olderPages: [olderPage],
|
||||
);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'msg21',
|
||||
createdAt: 1021,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Message 21'), findsOneWidget);
|
||||
expect(findRichText('Message 50'), findsNothing);
|
||||
expect(messagesNotifier.fetchOlderCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('does not load history for threaded-only unread events', (
|
||||
tester,
|
||||
) async {
|
||||
final newestPage = [
|
||||
for (var i = 50; i < 100; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final olderPage = [
|
||||
for (var i = 0; i < 50; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(
|
||||
newestPage,
|
||||
olderPages: [olderPage],
|
||||
);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'thread-reply',
|
||||
createdAt: 1021,
|
||||
rootId: 'thread-root',
|
||||
highPriority: true,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: true,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(messagesNotifier.fetchOlderCalls, 0);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('caps unread target history loading', (tester) async {
|
||||
final newestPage = [
|
||||
for (var i = 250; i < 300; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final olderPages = [
|
||||
for (var page = 4; page >= 0; page--)
|
||||
[
|
||||
for (var i = page * 50; i < (page + 1) * 50; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
],
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(
|
||||
newestPage,
|
||||
olderPages: olderPages,
|
||||
);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'missing-target',
|
||||
createdAt: 1001,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
makeObservedUnreadEvent(
|
||||
id: 'msg275',
|
||||
createdAt: 1275,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(messagesNotifier.fetchOlderCalls, 4);
|
||||
final unreadButton = find.byKey(
|
||||
const ValueKey('channel-jump-to-oldest-unread'),
|
||||
);
|
||||
expect(unreadButton, findsOneWidget);
|
||||
await tester.tap(unreadButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(findRichText('Message 275'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('stops loading the unread boundary after a failed page', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
for (var i = 50; i < 100; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(
|
||||
messages,
|
||||
failOlderFetch: true,
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
forcedUnreadContexts: {_channelId: _channelId},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
readStateNotifier: readState,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.bySemanticsLabel('Loading older messages'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('falls back when the oldest unread row is deleted', (
|
||||
tester,
|
||||
) async {
|
||||
final messagesNotifier = _FakeMessagesNotifier([
|
||||
_textMsg(
|
||||
id: 'deleted-oldest',
|
||||
pubkey: 'alice',
|
||||
content: 'Deleted oldest',
|
||||
createdAt: 1021,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'reachable-unread',
|
||||
pubkey: 'alice',
|
||||
content: 'Reachable unread',
|
||||
createdAt: 1022,
|
||||
),
|
||||
_deletion(
|
||||
id: 'delete-oldest',
|
||||
targetIds: ['deleted-oldest'],
|
||||
createdAt: 1023,
|
||||
),
|
||||
]);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'deleted-oldest',
|
||||
createdAt: 1021,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
makeObservedUnreadEvent(
|
||||
id: 'reachable-unread',
|
||||
createdAt: 1022,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final unreadButton = find.byKey(
|
||||
const ValueKey('channel-jump-to-oldest-unread'),
|
||||
);
|
||||
expect(unreadButton, findsOneWidget);
|
||||
await tester.tap(unreadButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(findRichText('Reachable unread'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pages past a loaded forced unread for an older target', (
|
||||
tester,
|
||||
) async {
|
||||
final newestPage = [
|
||||
for (var i = 50; i < 100; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final olderPage = [
|
||||
for (var i = 0; i < 50; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(
|
||||
newestPage,
|
||||
olderPages: [olderPage],
|
||||
);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'msg21',
|
||||
createdAt: 1021,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1020},
|
||||
version: 0,
|
||||
forcedUnreadContexts: {'msg:msg75': _channelId},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(messagesNotifier.fetchOlderCalls, 1);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(findRichText('Message 21'), findsOneWidget);
|
||||
expect(findRichText('Message 75'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('targets the oldest message-level forced unread', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 2000},
|
||||
version: 0,
|
||||
forcedUnreadContexts: {
|
||||
'msg:msg20': _channelId,
|
||||
'msg:msg5': _channelId,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Message 5'), findsOneWidget);
|
||||
expect(findRichText('Message 20'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('ignores newer events absent from observed unread state', (
|
||||
tester,
|
||||
) async {
|
||||
final messages = [
|
||||
_textMsg(
|
||||
id: 'read-message',
|
||||
pubkey: 'alice',
|
||||
content: 'Already read',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'self-message',
|
||||
pubkey: 'self',
|
||||
content: 'My own newer message',
|
||||
createdAt: 1100,
|
||||
),
|
||||
_systemMsg(
|
||||
id: 'system-message',
|
||||
payload: const {'type': 'channel_created'},
|
||||
createdAt: 1200,
|
||||
),
|
||||
];
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1000},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: messages,
|
||||
readStateNotifier: readState,
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('can jump back to latest after a non-drag user scroll', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -1398,6 +1974,67 @@ void main() {
|
||||
expect(findRichText('Newest live update'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'gives an initial deep link precedence over unread navigation',
|
||||
(tester) async {
|
||||
final initialMessages = [
|
||||
for (var i = 0; i < 40; i++)
|
||||
_textMsg(
|
||||
id: 'msg$i',
|
||||
pubkey: 'alice',
|
||||
content: 'Message $i',
|
||||
createdAt: 1000 + i,
|
||||
),
|
||||
];
|
||||
final messagesNotifier = _FakeMessagesNotifier(initialMessages);
|
||||
final channelsNotifier = _FakeChannelsNotifier(
|
||||
[_testChannel],
|
||||
observedUnread: {
|
||||
_channelId: [
|
||||
makeObservedUnreadEvent(
|
||||
id: 'msg5',
|
||||
createdAt: 1005,
|
||||
rootId: null,
|
||||
highPriority: false,
|
||||
channelType: 'stream',
|
||||
isThreadedReply: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {_channelId: 1004},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
messagesNotifier: messagesNotifier,
|
||||
channelsNotifier: channelsNotifier,
|
||||
readStateNotifier: readState,
|
||||
initialMessageId: 'msg20',
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(findRichText('Message 20'), findsOneWidget);
|
||||
expect(findRichText('Message 5'), findsNothing);
|
||||
expect(
|
||||
find.byKey(const ValueKey('channel-jump-to-oldest-unread')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(messagesNotifier.fetchOlderCalls, 0);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'keeps a deep-linked message in view when its page arrives after a '
|
||||
'small scroll near the latest message',
|
||||
@@ -3187,12 +3824,18 @@ Channel _channel({required String id, required String name}) => Channel(
|
||||
class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
List<NostrEvent> _messages;
|
||||
bool _hasLoadedMessages;
|
||||
final List<List<NostrEvent>> _olderPages;
|
||||
final bool failOlderFetch;
|
||||
int fetchOlderCalls = 0;
|
||||
|
||||
_FakeMessagesNotifier(
|
||||
this._messages, {
|
||||
String channelId = _channelId,
|
||||
bool hasLoadedMessages = true,
|
||||
List<List<NostrEvent>> olderPages = const [],
|
||||
this.failOlderFetch = false,
|
||||
}) : _hasLoadedMessages = hasLoadedMessages,
|
||||
_olderPages = [...olderPages],
|
||||
super(channelId);
|
||||
|
||||
@override
|
||||
@@ -3202,10 +3845,17 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier {
|
||||
bool get hasLoadedMessages => _hasLoadedMessages;
|
||||
|
||||
@override
|
||||
bool get reachedOldest => true;
|
||||
bool get reachedOldest => _olderPages.isEmpty && !failOlderFetch;
|
||||
|
||||
@override
|
||||
Future<bool> fetchOlder() async => false;
|
||||
Future<bool> fetchOlder() async {
|
||||
fetchOlderCalls += 1;
|
||||
if (failOlderFetch || _olderPages.isEmpty) return false;
|
||||
_messages = [..._olderPages.removeAt(0), ..._messages]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
state = AsyncData(_messages);
|
||||
return true;
|
||||
}
|
||||
|
||||
void setMessages(List<NostrEvent> messages) {
|
||||
_messages = messages;
|
||||
@@ -3286,7 +3936,19 @@ class _FakeUserCacheNotifier extends UserCacheNotifier {
|
||||
|
||||
class _FakeChannelsNotifier extends ChannelsNotifier {
|
||||
List<Channel> _channels;
|
||||
_FakeChannelsNotifier(this._channels);
|
||||
final Map<String, Map<String, ObservedUnreadEvent>> _observedUnread;
|
||||
|
||||
_FakeChannelsNotifier(
|
||||
this._channels, {
|
||||
Map<String, List<ObservedUnreadEvent>> observedUnread = const {},
|
||||
}) : _observedUnread = {
|
||||
for (final entry in observedUnread.entries)
|
||||
entry.key: {for (final event in entry.value) event.id: event},
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, Map<String, ObservedUnreadEvent>>
|
||||
get observedUnreadEventsByChannel => _observedUnread;
|
||||
|
||||
@override
|
||||
Future<List<Channel>> build() => SynchronousFuture(_channels);
|
||||
|
||||
Reference in New Issue
Block a user