fix tablet workspace review findings

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-03 13:37:23 +01:00
parent e0d7e628ba
commit 09d1e24a7e
5 changed files with 188 additions and 6 deletions
@@ -60,6 +60,10 @@ class ActivityPage extends HookConsumerWidget {
final filter = useState(InboxFilter.all);
final unreadOnly = useState(false);
final selectedItemId = useState<String?>(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<FeedItem?>(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<InboxItem?>().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
+14 -2
View File
@@ -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<Channel?>(null);
final pendingCommunityId = useState<String?>(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<String?>(activeCommunityId);
if (selectedChannelCommunityId.value != activeCommunityId) {
selectedChannel.value = null;
selectedChannelCommunityId.value = activeCommunityId;
}
final channelsAsync = ref.watch(channelsProvider);
final hasActivatedPendingCommunity =
pendingCommunityId.value != null &&
@@ -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 <Channel>[])
@@ -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<ChannelDetailPage>(
find.byType(ChannelDetailPage),
);
expect(detail.initialMessageId, 'oldest-unread');
},
);
testWidgets('keeps bottom clearance for the floating tab bar', (
tester,
) async {
+97 -1
View File
@@ -25,6 +25,7 @@ void main() {
CommunityListNotifier? communityListNotifier,
Community? activeCommunity,
List<Channel>? 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<Community?> {
@override
Community? build() => null;
void select(Community? community) => state = community;
}
class _FakeUserStatusNotifier extends UserStatusNotifier {
_FakeUserStatusNotifier(this._status);