diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 2f47f05f44..6eb80fca96 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1546,6 +1546,15 @@ sealed class ComposeBoxController { final content = ComposeContentController(); final contentFocusNode = FocusNode(); + /// If no input is focused, requests focus on the appropriate input. + /// + /// A convenience method to encapsulate choosing the topic or content input + /// when both exist (see [StreamComposeBoxController.requestFocusIfUnfocused]). + void requestFocusIfUnfocused() { + if (contentFocusNode.hasFocus) return; + contentFocusNode.requestFocus(); + } + @mustCallSuper void dispose() { content.dispose(); @@ -1609,6 +1618,15 @@ class StreamComposeBoxController extends ComposeBoxController { final ValueNotifier topicInteractionStatus = ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen); + @override void requestFocusIfUnfocused() { + if (topicFocusNode.hasFocus || contentFocusNode.hasFocus) return; + if (topicInteractionStatus.value == ComposeTopicInteractionStatus.notEditingNotChosen) { + topicFocusNode.requestFocus(); + } else { + contentFocusNode.requestFocus(); + } + } + @override void dispose() { topic.dispose(); diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index e25445e514..6d114a8253 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -522,6 +522,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat model.fetchInitial(); } + bool _prevFetched = false; + void _modelChanged() { if (model.narrow != widget.narrow) { // Either: @@ -535,6 +537,15 @@ class _MessageListState extends State with PerAccountStoreAwareStat // The actual state lives in the [MessageListView] model. // This method was called because that just changed. }); + + if (!_prevFetched && model.fetched && model.messages.isEmpty) { + // If the fetch came up empty, there's nothing to read, + // so opening the keyboard won't be bothersome and could be helpful. + // It's definitely helpful if we got here from the new-DM page. + MessageListPage.ancestorOf(context) + .composeBoxState?.controller.requestFocusIfUnfocused(); + } + _prevFetched = model.fetched; } void _handleScrollMetrics(ScrollMetrics scrollMetrics) { diff --git a/test/widgets/compose_box_checks.dart b/test/widgets/compose_box_checks.dart index b93ff7f1bf..349e8cd971 100644 --- a/test/widgets/compose_box_checks.dart +++ b/test/widgets/compose_box_checks.dart @@ -11,6 +11,11 @@ extension ComposeBoxControllerChecks on Subject { Subject get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode'); } +extension StreamComposeBoxControllerChecks on Subject { + Subject get topic => has((c) => c.topic, 'topic'); + Subject get topicFocusNode => has((c) => c.topicFocusNode, 'topicFocusNode'); +} + extension EditMessageComposeBoxControllerChecks on Subject { Subject get messageId => has((c) => c.messageId, 'messageId'); Subject get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent'); diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart index b4de306aa3..f687fddc97 100644 --- a/test/widgets/compose_box_test.dart +++ b/test/widgets/compose_box_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_checks/flutter_checks.dart'; @@ -56,14 +57,23 @@ void main() { User? selfUser, List otherUsers = const [], List streams = const [], + List? messages, bool? mandatoryTopics, int? zulipFeatureLevel, }) async { if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) { - assert(streams.any((stream) => stream.streamId == streamId), + final channel = streams.firstWhereOrNull((s) => s.streamId == streamId); + assert(channel != null, 'Add a channel with "streamId" the same as of $narrow.streamId to the store.'); + if (narrow is ChannelNarrow) { + // By default, bypass the complexity where the topic input is autofocused + // on an empty fetch, by making the fetch not empty. (In particular that + // complexity includes a getStreamTopics fetch for topic autocomplete.) + messages ??= [eg.streamMessage(stream: channel)]; + } } addTearDown(testBinding.reset); + messages ??= []; selfUser ??= eg.selfUser; zulipFeatureLevel ??= eg.futureZulipFeatureLevel; final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel); @@ -81,7 +91,11 @@ void main() { connection = store.connection as FakeApiConnection; connection.prepare(json: - eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson()); + eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson()); + if (narrow is ChannelNarrow && messages.isEmpty) { + // The topic input will autofocus, triggering a getStreamTopics request. + connection.prepare(json: GetStreamTopicsResult(topics: []).toJson()); + } await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id, child: MessageListPage(initNarrow: narrow))); await tester.pumpAndSettle(); @@ -134,6 +148,61 @@ void main() { await tester.pump(Duration.zero); } + group('auto focus', () { + testWidgets('ChannelNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: [eg.streamMessage(stream: channel)]); + check(controller).isA() + .topicFocusNode.hasFocus.isFalse(); + }); + + testWidgets('ChannelNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: ChannelNarrow(channel.streamId), + streams: [channel], + messages: []); + check(controller).isA() + .topicFocusNode.hasFocus.isTrue(); + }); + + testWidgets('TopicNarrow, non-empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: [eg.streamMessage(stream: channel, topic: 'topic')]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('TopicNarrow, empty fetch', (tester) async { + final channel = eg.stream(); + await prepareComposeBox(tester, + narrow: TopicNarrow(channel.streamId, eg.t('topic')), + streams: [channel], + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + + testWidgets('DmNarrow, non-empty fetch', (tester) async { + final user = eg.user(); + await prepareComposeBox(tester, + narrow: DmNarrow.withUser(user.userId, selfUserId: store.selfUserId), + messages: [eg.dmMessage(from: user, to: [store.selfUser])]); + check(controller).isNotNull().contentFocusNode.hasFocus.isFalse(); + }); + + testWidgets('DmNarrow, empty fetch', (tester) async { + await prepareComposeBox(tester, + narrow: DmNarrow.withUser(eg.user().userId, selfUserId: store.selfUserId), + messages: []); + check(controller).isNotNull().contentFocusNode.hasFocus.isTrue(); + }); + }); + group('ComposeBoxTheme', () { test('lerp light to dark, no crash', () { final a = ComposeBoxTheme.light;