diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 0ab65cc6c..2941fdfb2 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -60,6 +60,10 @@ class ActivityPage extends HookConsumerWidget { final filter = useState(InboxFilter.all); final unreadOnly = useState(false); final selectedItemId = useState(null); + // Retain the event selected before its row is marked read. A grouped row + // otherwise recomputes its deep link after the read marker changes and + // opens the latest event instead of the oldest unread one the user chose. + final selectedItemTarget = useState(null); final isWideInbox = MediaQuery.sizeOf(context).width >= _wideInboxBreakpoint; final headerTitleStyle = context.textTheme.titleMedium?.copyWith( @@ -103,7 +107,10 @@ class ActivityPage extends HookConsumerWidget { final hasSelectedItem = visibleItems.any( (item) => item.id == selectedItemId.value, ); - if (!hasSelectedItem) selectedItemId.value = visibleItems.first.id; + if (!hasSelectedItem) { + selectedItemId.value = visibleItems.first.id; + selectedItemTarget.value = null; + } return null; }, [isWideInbox, visibleItemIdsKey]); final selectedItem = visibleItems.cast().firstWhere( @@ -189,6 +196,7 @@ class ActivityPage extends HookConsumerWidget { if (isWideInbox) { selectedItemId.value = item.id; + selectedItemTarget.value = target; markItemRead(item); return; } @@ -335,6 +343,7 @@ class ActivityPage extends HookConsumerWidget { draftCount: drafts.length, onChanged: (nextFilter) { selectedItemId.value = null; + selectedItemTarget.value = null; filter.value = nextFilter; }, ), @@ -369,7 +378,8 @@ class ActivityPage extends HookConsumerWidget { ); } - final selectedTarget = detailTarget(selectedItem); + final selectedTarget = + selectedItemTarget.value ?? detailTarget(selectedItem); final selectedChannelId = selectedTarget?.channelId; final selectedChannel = selectedChannelId == null ? null diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 8ede13dae..0663f7e95 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -17,7 +17,6 @@ import '../../shared/widgets/skeleton.dart'; import '../activity/activity_page.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; -import '../channels/channel_management_provider.dart'; import '../channels/channels_page.dart'; import '../channels/channels_provider.dart'; import '../channels/dm_channel_labels.dart'; @@ -90,7 +89,20 @@ class HomePage extends HookConsumerWidget { final tabIndex = useState(isWide ? 1 : 0); final selectedChannel = useState(null); final pendingCommunityId = useState(null); - final activeCommunityId = ref.watch(activeCommunityProvider).value?.id; + // Clear the tablet selection as soon as the active-community state begins + // changing. The channel provider intentionally retains its last result + // while the next relay connects, so retaining the selection here could + // briefly render (and query) an old-community channel in the new workspace. + final activeCommunityId = ref.watch( + activeCommunityProvider.select( + (value) => value.unwrapPrevious().value?.id, + ), + ); + final selectedChannelCommunityId = useRef(activeCommunityId); + if (selectedChannelCommunityId.value != activeCommunityId) { + selectedChannel.value = null; + selectedChannelCommunityId.value = activeCommunityId; + } final channelsAsync = ref.watch(channelsProvider); final hasActivatedPendingCommunity = pendingCommunityId.value != null && diff --git a/mobile/lib/features/home/home_page/wide_navigation.dart b/mobile/lib/features/home/home_page/wide_navigation.dart index 7643b01e4..eef056ffd 100644 --- a/mobile/lib/features/home/home_page/wide_navigation.dart +++ b/mobile/lib/features/home/home_page/wide_navigation.dart @@ -26,7 +26,10 @@ class _WideNavigationSidebar extends HookConsumerWidget { final colors = context.colors; final sidebarGradient = context.appColors.topSectionGradient; final sidebarPalette = _wideSidebarPalette(context); - final currentPubkey = ref.watch(currentPubkeyProvider); + // The workspace sidebar only needs the signed-in profile identity to + // label direct-message participants. Keep that navigation concern out of + // the channel-management implementation. + final currentPubkey = ref.watch(profileProvider).value?.pubkey; final profiles = ref.watch(userCacheProvider); final channelsAsync = ref.watch(channelsProvider); final channels = (channelsAsync.asData?.value ?? const []) diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 8c1f05f5a..bddefdff5 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -209,6 +209,67 @@ void main() { ); }); + testWidgets( + 'keeps the oldest unread deep link after a wide inbox row is read', + (tester) async { + tester.view.physicalSize = const Size(1180, 820); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final oldestUnread = FeedItem( + id: 'oldest-unread', + kind: 9, + pubkey: 'alice_pk', + content: 'First unread reply', + createdAt: now - 120, + channelId: 'ch1', + channelName: 'general', + tags: const [ + ['e', 'thread-root', '', 'root'], + ['e', 'oldest-unread', '', 'reply'], + ], + category: 'activity', + ); + final latestUnread = FeedItem( + id: 'latest-unread', + kind: 9, + pubkey: 'alice_pk', + content: 'Latest unread reply', + createdAt: now - 60, + channelId: 'ch1', + channelName: 'general', + tags: const [ + ['e', 'thread-root', '', 'root'], + ['e', 'latest-unread', '', 'reply'], + ], + category: 'activity', + ); + final feed = HomeFeedResponse( + mentions: const [], + needsAction: const [], + activity: [oldestUnread, latestUnread], + agentActivity: const [], + ); + + await tester.pumpWidget( + await buildTestable( + feed: feed, + readContexts: {'thread:thread-root': now - 180}, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('inbox-row-latest-unread'))); + await tester.pump(); + + final detail = tester.widget( + find.byType(ChannelDetailPage), + ); + expect(detail.initialMessageId, 'oldest-unread'); + }, + ); + testWidgets('keeps bottom clearance for the floating tab bar', ( tester, ) async { diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index acf4e7737..e24384004 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -25,6 +25,7 @@ void main() { CommunityListNotifier? communityListNotifier, Community? activeCommunity, List? channels, + bool usesMutableActiveCommunity = false, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -37,7 +38,11 @@ void main() { ), if (communityListNotifier != null) communityListProvider.overrideWith(() => communityListNotifier), - if (activeCommunity != null) + if (usesMutableActiveCommunity) + activeCommunityProvider.overrideWith( + (ref) async => ref.watch(_mutableActiveCommunityProvider), + ) + else if (activeCommunity != null) activeCommunityProvider.overrideWith((ref) async => activeCommunity), if (channels != null) channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)), @@ -303,6 +308,85 @@ void main() { expect(find.byKey(const Key('community-switcher-sheet')), findsOneWidget); }); + testWidgets('clears a selected tablet channel when the community changes', ( + tester, + ) async { + tester.view.physicalSize = const Size(1180, 820); + tester.view.devicePixelRatio = 1; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + await buildHome( + usesMutableActiveCommunity: true, + channels: [ + Channel( + id: 'alpha-channel', + name: 'alpha', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'me', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ), + ], + ), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(HomePage)), + ); + container + .read(_mutableActiveCommunityProvider.notifier) + .select( + Community( + id: 'alpha', + name: 'Alpha', + relayUrl: 'wss://alpha.example.com', + addedAt: DateTime(2026), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('wide-channel-alpha-channel'))); + await tester.pump(); + + expect( + tester + .getSemantics( + find.byKey(const ValueKey('wide-channel-alpha-channel')), + ) + .flagsCollection + .isSelected, + Tristate.isTrue, + ); + + container + .read(_mutableActiveCommunityProvider.notifier) + .select( + Community( + id: 'bravo', + name: 'Bravo', + relayUrl: 'wss://bravo.example.com', + addedAt: DateTime(2026), + ), + ); + await tester.pump(); + + expect( + tester + .getSemantics( + find.byKey(const ValueKey('wide-channel-alpha-channel')), + ) + .flagsCollection + .isSelected, + Tristate.isFalse, + ); + expect(find.byKey(const Key('wide-navigation-inbox')), findsOneWidget); + }); + testWidgets('extends the Buzz gradient through the iPad sidebar', ( tester, ) async { @@ -655,6 +739,18 @@ void main() { Widget _buildSettingsPage(BuildContext context) => const SizedBox.shrink(); +final _mutableActiveCommunityProvider = + NotifierProvider<_MutableActiveCommunityNotifier, Community?>( + _MutableActiveCommunityNotifier.new, + ); + +class _MutableActiveCommunityNotifier extends Notifier { + @override + Community? build() => null; + + void select(Community? community) => state = community; +} + class _FakeUserStatusNotifier extends UserStatusNotifier { _FakeUserStatusNotifier(this._status);