Skip to content

Commit 211b545

Browse files
committed
msglist: When initial message fetch comes up empty, auto-focus compose box
This is part of our plan to streamline the new-DM UI: when you start a new DM conversation with no history, we should auto-focus the content input in the compose box. Fixes: #1543
1 parent 0338752 commit 211b545

File tree

4 files changed

+112
-2
lines changed

4 files changed

+112
-2
lines changed

lib/widgets/compose_box.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,15 @@ sealed class ComposeBoxController {
15461546
final content = ComposeContentController();
15471547
final contentFocusNode = FocusNode();
15481548

1549+
/// If no input is focused, requests focus on the appropriate input.
1550+
///
1551+
/// This encapsulates choosing the topic or content input
1552+
/// when both exist (see [StreamComposeBoxController.requestFocusIfUnfocused]).
1553+
void requestFocusIfUnfocused() {
1554+
if (contentFocusNode.hasFocus) return;
1555+
contentFocusNode.requestFocus();
1556+
}
1557+
15491558
@mustCallSuper
15501559
void dispose() {
15511560
content.dispose();
@@ -1609,6 +1618,19 @@ class StreamComposeBoxController extends ComposeBoxController {
16091618
final ValueNotifier<ComposeTopicInteractionStatus> topicInteractionStatus =
16101619
ValueNotifier(ComposeTopicInteractionStatus.notEditingNotChosen);
16111620

1621+
@override void requestFocusIfUnfocused() {
1622+
if (topicFocusNode.hasFocus || contentFocusNode.hasFocus) return;
1623+
switch (topicInteractionStatus.value) {
1624+
case ComposeTopicInteractionStatus.notEditingNotChosen:
1625+
topicFocusNode.requestFocus();
1626+
case ComposeTopicInteractionStatus.isEditing:
1627+
// (should be impossible given early-return on topicFocusNode.hasFocus)
1628+
break;
1629+
case ComposeTopicInteractionStatus.hasChosen:
1630+
contentFocusNode.requestFocus();
1631+
}
1632+
}
1633+
16121634
@override
16131635
void dispose() {
16141636
topic.dispose();

lib/widgets/message_list.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
526526
model.fetchInitial();
527527
}
528528

529+
bool _prevFetched = false;
530+
529531
void _modelChanged() {
530532
if (model.narrow != widget.narrow) {
531533
// Either:
@@ -539,6 +541,15 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
539541
// The actual state lives in the [MessageListView] model.
540542
// This method was called because that just changed.
541543
});
544+
545+
if (!_prevFetched && model.fetched && model.messages.isEmpty) {
546+
// If the fetch came up empty, there's nothing to read,
547+
// so opening the keyboard won't be bothersome and could be helpful.
548+
// It's definitely helpful if we got here from the new-DM page.
549+
MessageListPage.ancestorOf(context)
550+
.composeBoxState?.controller.requestFocusIfUnfocused();
551+
}
552+
_prevFetched = model.fetched;
542553
}
543554

544555
void _handleScrollMetrics(ScrollMetrics scrollMetrics) {

test/widgets/compose_box_checks.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ extension ComposeBoxControllerChecks on Subject<ComposeBoxController> {
1111
Subject<FocusNode> get contentFocusNode => has((c) => c.contentFocusNode, 'contentFocusNode');
1212
}
1313

14+
extension StreamComposeBoxControllerChecks on Subject<StreamComposeBoxController> {
15+
Subject<ComposeTopicController> get topic => has((c) => c.topic, 'topic');
16+
Subject<FocusNode> get topicFocusNode => has((c) => c.topicFocusNode, 'topicFocusNode');
17+
}
18+
1419
extension EditMessageComposeBoxControllerChecks on Subject<EditMessageComposeBoxController> {
1520
Subject<int> get messageId => has((c) => c.messageId, 'messageId');
1621
Subject<String?> get originalRawContent => has((c) => c.originalRawContent, 'originalRawContent');

test/widgets/compose_box_test.dart

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'dart:io';
44

55
import 'package:checks/checks.dart';
6+
import 'package:collection/collection.dart';
67
import 'package:crypto/crypto.dart';
78
import 'package:file_picker/file_picker.dart';
89
import 'package:flutter_checks/flutter_checks.dart';
@@ -56,14 +57,23 @@ void main() {
5657
User? selfUser,
5758
List<User> otherUsers = const [],
5859
List<ZulipStream> streams = const [],
60+
List<Message>? messages,
5961
bool? mandatoryTopics,
6062
int? zulipFeatureLevel,
6163
}) async {
6264
if (narrow case ChannelNarrow(:var streamId) || TopicNarrow(: var streamId)) {
63-
assert(streams.any((stream) => stream.streamId == streamId),
65+
final channel = streams.firstWhereOrNull((s) => s.streamId == streamId);
66+
assert(channel != null,
6467
'Add a channel with "streamId" the same as of $narrow.streamId to the store.');
68+
if (narrow is ChannelNarrow) {
69+
// By default, bypass the complexity where the topic input is autofocused
70+
// on an empty fetch, by making the fetch not empty. (In particular that
71+
// complexity includes a getStreamTopics fetch for topic autocomplete.)
72+
messages ??= [eg.streamMessage(stream: channel)];
73+
}
6574
}
6675
addTearDown(testBinding.reset);
76+
messages ??= [];
6777
selfUser ??= eg.selfUser;
6878
zulipFeatureLevel ??= eg.futureZulipFeatureLevel;
6979
final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel);
@@ -81,7 +91,11 @@ void main() {
8191
connection = store.connection as FakeApiConnection;
8292

8393
connection.prepare(json:
84-
eg.newestGetMessagesResult(foundOldest: true, messages: []).toJson());
94+
eg.newestGetMessagesResult(foundOldest: true, messages: messages).toJson());
95+
if (narrow is ChannelNarrow && messages.isEmpty) {
96+
// The topic input will autofocus, triggering a getStreamTopics request.
97+
connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
98+
}
8599
await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
86100
child: MessageListPage(initNarrow: narrow)));
87101
await tester.pumpAndSettle();
@@ -134,6 +148,64 @@ void main() {
134148
await tester.pump(Duration.zero);
135149
}
136150

151+
group('auto focus', () {
152+
testWidgets('ChannelNarrow, non-empty fetch', (tester) async {
153+
final channel = eg.stream();
154+
await prepareComposeBox(tester,
155+
narrow: ChannelNarrow(channel.streamId),
156+
streams: [channel],
157+
messages: [eg.streamMessage(stream: channel)]);
158+
check(controller).isA<StreamComposeBoxController>()
159+
..topicFocusNode.hasFocus.isFalse()
160+
..contentFocusNode.hasFocus.isFalse();
161+
});
162+
163+
testWidgets('ChannelNarrow, empty fetch', (tester) async {
164+
final channel = eg.stream();
165+
await prepareComposeBox(tester,
166+
narrow: ChannelNarrow(channel.streamId),
167+
streams: [channel],
168+
messages: []);
169+
check(controller).isA<StreamComposeBoxController>()
170+
.topicFocusNode.hasFocus.isTrue();
171+
});
172+
173+
testWidgets('TopicNarrow, non-empty fetch', (tester) async {
174+
final channel = eg.stream();
175+
await prepareComposeBox(tester,
176+
narrow: TopicNarrow(channel.streamId, eg.t('topic')),
177+
streams: [channel],
178+
messages: [eg.streamMessage(stream: channel, topic: 'topic')]);
179+
check(controller).isNotNull().contentFocusNode.hasFocus.isFalse();
180+
});
181+
182+
testWidgets('TopicNarrow, empty fetch', (tester) async {
183+
final channel = eg.stream();
184+
await prepareComposeBox(tester,
185+
narrow: TopicNarrow(channel.streamId, eg.t('topic')),
186+
streams: [channel],
187+
messages: []);
188+
check(controller).isNotNull().contentFocusNode.hasFocus.isTrue();
189+
});
190+
191+
testWidgets('DmNarrow, non-empty fetch', (tester) async {
192+
final user = eg.user();
193+
await prepareComposeBox(tester,
194+
selfUser: eg.selfUser,
195+
narrow: DmNarrow.withUser(user.userId, selfUserId: eg.selfUser.userId),
196+
messages: [eg.dmMessage(from: user, to: [eg.selfUser])]);
197+
check(controller).isNotNull().contentFocusNode.hasFocus.isFalse();
198+
});
199+
200+
testWidgets('DmNarrow, empty fetch', (tester) async {
201+
await prepareComposeBox(tester,
202+
selfUser: eg.selfUser,
203+
narrow: DmNarrow.withUser(eg.user().userId, selfUserId: eg.selfUser.userId),
204+
messages: []);
205+
check(controller).isNotNull().contentFocusNode.hasFocus.isTrue();
206+
});
207+
});
208+
137209
group('ComposeBoxTheme', () {
138210
test('lerp light to dark, no crash', () {
139211
final a = ComposeBoxTheme.light;

0 commit comments

Comments
 (0)