Skip to content

Commit 24531db

Browse files
committed
compose_box: Send typing notices on content/focus changes
For a compose box with topic input, the field `destination` contains a mutable state computed from the topic text. This implementation is similar to hintText's to still keep everything up-to-date. When testing typing activities that do not end with a "typing stopped" notice, we need to wait for the idle timer to expire so that the test does not end with pending timers. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 92facbd commit 24531db

File tree

3 files changed

+219
-29
lines changed

3 files changed

+219
-29
lines changed

lib/widgets/compose_box.dart

+52
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,14 @@ class ComposeContentController extends ComposeController<ContentValidationError>
272272
class _ContentInput extends StatefulWidget {
273273
const _ContentInput({
274274
required this.narrow,
275+
required this.destination,
275276
required this.controller,
276277
required this.focusNode,
277278
required this.hintText,
278279
});
279280

280281
final Narrow narrow;
282+
final SendableNarrow destination;
281283
final ComposeContentController controller;
282284
final FocusNode focusNode;
283285
final String hintText;
@@ -287,6 +289,50 @@ class _ContentInput extends StatefulWidget {
287289
}
288290

289291
class _ContentInputState extends State<_ContentInput> {
292+
@override
293+
void initState() {
294+
super.initState();
295+
widget.controller.addListener(_contentChanged);
296+
widget.focusNode.addListener(_focusChanged);
297+
}
298+
299+
@override
300+
void didUpdateWidget(covariant _ContentInput oldWidget) {
301+
super.didUpdateWidget(oldWidget);
302+
if (widget.controller != oldWidget.controller) {
303+
oldWidget.controller.removeListener(_contentChanged);
304+
widget.controller.addListener(_contentChanged);
305+
}
306+
if (widget.focusNode != oldWidget.focusNode) {
307+
oldWidget.focusNode.removeListener(_focusChanged);
308+
widget.focusNode.addListener(_focusChanged);
309+
}
310+
}
311+
312+
@override
313+
void dispose() {
314+
widget.controller.removeListener(_contentChanged);
315+
widget.focusNode.removeListener(_focusChanged);
316+
super.dispose();
317+
}
318+
319+
void _contentChanged() {
320+
final store = PerAccountStoreWidget.of(context);
321+
(widget.controller.text.isEmpty)
322+
? store.typingNotifier.stoppedComposing()
323+
: store.typingNotifier.keystroke(widget.destination);
324+
}
325+
326+
void _focusChanged() {
327+
if (widget.focusNode.hasFocus) {
328+
// Content input getting focus doesn't necessarily mean that
329+
// the user started typing, so do nothing.
330+
return;
331+
}
332+
final store = PerAccountStoreWidget.of(context);
333+
store.typingNotifier.stoppedComposing();
334+
}
335+
290336
@override
291337
Widget build(BuildContext context) {
292338
ColorScheme colorScheme = Theme.of(context).colorScheme;
@@ -375,6 +421,7 @@ class _StreamContentInputState extends State<_StreamContentInput> {
375421
?? zulipLocalizations.composeBoxUnknownChannelName;
376422
return _ContentInput(
377423
narrow: widget.narrow,
424+
destination: TopicNarrow(widget.narrow.streamId, _topicTextNormalized),
378425
controller: widget.controller,
379426
focusNode: widget.focusNode,
380427
hintText: zulipLocalizations.composeBoxChannelContentHint(streamName, _topicTextNormalized));
@@ -451,6 +498,7 @@ class _FixedDestinationContentInput extends StatelessWidget {
451498
Widget build(BuildContext context) {
452499
return _ContentInput(
453500
narrow: narrow,
501+
destination: narrow,
454502
controller: controller,
455503
focusNode: focusNode,
456504
hintText: _hintText(context));
@@ -823,6 +871,10 @@ class _SendButtonState extends State<_SendButton> {
823871
final content = widget.contentController.textNormalized;
824872

825873
widget.contentController.clear();
874+
// The following `stoppedComposing` call is currently redundant,
875+
// because clearing input sends a "typing stopped" notice.
876+
// It will be necessary once we resolve #720.
877+
store.typingNotifier.stoppedComposing();
826878

827879
try {
828880
// TODO(#720) clear content input only on success response;

test/model/typing_status_test.dart

+27-27
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,33 @@ import '../stdlib_checks.dart';
1717
import 'binding.dart';
1818
import 'test_store.dart';
1919

20+
void checkSetTypingStatusRequests(
21+
List<http.BaseRequest> requests,
22+
List<(TypingOp, SendableNarrow)> expected,
23+
) {
24+
Condition<Object?> conditionTypingRequest(Map<String, String> expected) {
25+
return (Subject<Object?> it) => it.isA<http.Request>()
26+
..method.equals('POST')
27+
..url.path.equals('/api/v1/typing')
28+
..bodyFields.deepEquals(expected);
29+
}
30+
31+
check(requests).deepEquals([
32+
for (final (op, narrow) in expected)
33+
switch (narrow) {
34+
TopicNarrow() => conditionTypingRequest({
35+
'type': 'channel',
36+
'op': op.toJson(),
37+
'stream_id': narrow.streamId.toString(),
38+
'topic': narrow.topic}),
39+
DmNarrow() => conditionTypingRequest({
40+
'type': 'direct',
41+
'op': op.toJson(),
42+
'to': jsonEncode(narrow.allRecipientIds)}),
43+
}
44+
]);
45+
}
46+
2047
void main() {
2148
TestZulipBinding.ensureInitialized();
2249

@@ -228,33 +255,6 @@ void main() {
228255
late FakeApiConnection connection;
229256
late TopicNarrow narrow;
230257

231-
void checkSetTypingStatusRequests(
232-
List<http.BaseRequest> requests,
233-
List<(TypingOp, SendableNarrow)> expected,
234-
) {
235-
Condition<Object?> conditionTypingRequest(Map<String, String> expected) {
236-
return (Subject<Object?> it) => it.isA<http.Request>()
237-
..method.equals('POST')
238-
..url.path.equals('/api/v1/typing')
239-
..bodyFields.deepEquals(expected);
240-
}
241-
242-
check(requests).deepEquals([
243-
for (final (op, narrow) in expected)
244-
switch (narrow) {
245-
TopicNarrow() => conditionTypingRequest({
246-
'type': 'channel',
247-
'op': op.toJson(),
248-
'stream_id': narrow.streamId.toString(),
249-
'topic': narrow.topic}),
250-
DmNarrow() => conditionTypingRequest({
251-
'type': 'direct',
252-
'op': op.toJson(),
253-
'to': jsonEncode(narrow.allRecipientIds)}),
254-
}
255-
]);
256-
}
257-
258258
void checkTypingRequest(TypingOp op, SendableNarrow narrow) =>
259259
checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]);
260260

test/widgets/compose_box_test.dart

+140-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:convert';
23

34
import 'package:checks/checks.dart';
@@ -15,13 +16,16 @@ import 'package:zulip/model/localizations.dart';
1516
import 'package:zulip/model/narrow.dart';
1617
import 'package:zulip/model/store.dart';
1718
import 'package:zulip/model/typing_status.dart';
19+
import 'package:zulip/widgets/app.dart';
1820
import 'package:zulip/widgets/compose_box.dart';
21+
import 'package:zulip/widgets/page.dart';
1922

2023
import '../api/fake_api.dart';
2124
import '../example_data.dart' as eg;
2225
import '../flutter_checks.dart';
2326
import '../model/binding.dart';
2427
import '../model/test_store.dart';
28+
import '../model/typing_status_test.dart';
2529
import '../stdlib_checks.dart';
2630
import 'dialog_checks.dart';
2731
import 'test_app.dart';
@@ -32,6 +36,9 @@ void main() {
3236
late PerAccountStore store;
3337
late FakeApiConnection connection;
3438

39+
final contentInputFinder = find.byWidgetPredicate(
40+
(widget) => widget is TextField && widget.controller is ComposeContentController);
41+
3542
Future<GlobalKey<ComposeBoxController>> prepareComposeBox(WidgetTester tester,
3643
{required Narrow narrow, List<User> users = const []}) async {
3744
addTearDown(testBinding.reset);
@@ -206,6 +213,139 @@ void main() {
206213
});
207214
});
208215

216+
group('ComposeBox typing notices', () {
217+
const narrow = TopicNarrow(123, 'some topic');
218+
219+
void checkTypingRequest(TypingOp op, SendableNarrow narrow) =>
220+
checkSetTypingStatusRequests(connection.takeRequests(), [(op, narrow)]);
221+
222+
Future<void> checkStartTyping(WidgetTester tester, SendableNarrow narrow) async {
223+
connection.prepare(json: {});
224+
await tester.enterText(contentInputFinder, 'hello world');
225+
checkTypingRequest(TypingOp.start, narrow);
226+
}
227+
228+
testWidgets('smoke TopicNarrow', (tester) async {
229+
await prepareComposeBox(tester, narrow: narrow);
230+
231+
await checkStartTyping(tester, narrow);
232+
233+
connection.prepare(json: {});
234+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
235+
checkTypingRequest(TypingOp.stop, narrow);
236+
});
237+
238+
testWidgets('smoke DmNarrow', (tester) async {
239+
final narrow = DmNarrow.withUsers(
240+
[eg.otherUser.userId], selfUserId: eg.selfUser.userId);
241+
await prepareComposeBox(tester, narrow: narrow);
242+
243+
await checkStartTyping(tester, narrow);
244+
245+
connection.prepare(json: {});
246+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
247+
checkTypingRequest(TypingOp.stop, narrow);
248+
});
249+
250+
testWidgets('smoke ChannelNarrow', (tester) async {
251+
const narrow = ChannelNarrow(123);
252+
final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
253+
await prepareComposeBox(tester, narrow: narrow);
254+
await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic);
255+
256+
await checkStartTyping(tester, destinationNarrow);
257+
258+
connection.prepare(json: {});
259+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
260+
checkTypingRequest(TypingOp.stop, destinationNarrow);
261+
});
262+
263+
testWidgets('clearing text sends a "typing stopped" notice', (tester) async {
264+
await prepareComposeBox(tester, narrow: narrow);
265+
266+
await checkStartTyping(tester, narrow);
267+
268+
connection.prepare(json: {});
269+
await tester.enterText(contentInputFinder, '');
270+
checkTypingRequest(TypingOp.stop, narrow);
271+
});
272+
273+
testWidgets('hitting send button sends a "typing stopped" notice', (tester) async {
274+
await prepareComposeBox(tester, narrow: narrow);
275+
276+
await checkStartTyping(tester, narrow);
277+
278+
connection.prepare(json: {});
279+
connection.prepare(json: SendMessageResult(id: 123).toJson());
280+
await tester.tap(find.byIcon(Icons.send));
281+
await tester.pump(Duration.zero);
282+
final requests = connection.takeRequests();
283+
checkSetTypingStatusRequests([requests.first], [(TypingOp.stop, narrow)]);
284+
check(requests).length.equals(2);
285+
});
286+
287+
Future<void> prepareComposeBoxWithNavigation(WidgetTester tester) async {
288+
addTearDown(testBinding.reset);
289+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
290+
291+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
292+
connection = store.connection as FakeApiConnection;
293+
294+
await tester.pumpWidget(const ZulipApp());
295+
await tester.pump();
296+
final navigator = await ZulipApp.navigator;
297+
unawaited(navigator.push(MaterialAccountWidgetRoute(
298+
accountId: eg.selfAccount.id, page: const ComposeBox(narrow: narrow))));
299+
await tester.pumpAndSettle();
300+
}
301+
302+
testWidgets('navigating away sends a "typing stopped" notice', (tester) async {
303+
await prepareComposeBoxWithNavigation(tester);
304+
305+
await checkStartTyping(tester, narrow);
306+
307+
connection.prepare(json: {});
308+
(await ZulipApp.navigator).pop();
309+
await tester.pump(Duration.zero);
310+
checkTypingRequest(TypingOp.stop, narrow);
311+
});
312+
313+
testWidgets('for content input, unfocusing sends a "typing stopped" notice', (tester) async {
314+
const narrow = ChannelNarrow(123);
315+
final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
316+
await prepareComposeBox(tester, narrow: narrow);
317+
await enterTopic(tester, narrow: narrow, topic: destinationNarrow.topic);
318+
319+
await checkStartTyping(tester, destinationNarrow);
320+
321+
connection.prepare(json: {});
322+
FocusManager.instance.primaryFocus!.unfocus();
323+
await tester.pump(Duration.zero);
324+
checkTypingRequest(TypingOp.stop, destinationNarrow);
325+
});
326+
327+
testWidgets('selection change sends a "typing started" notice', (tester) async {
328+
final controllerKey = await prepareComposeBox(tester, narrow: narrow);
329+
final composeBoxController = controllerKey.currentState!;
330+
331+
await checkStartTyping(tester, narrow);
332+
333+
connection.prepare(json: {});
334+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
335+
checkTypingRequest(TypingOp.stop, narrow);
336+
337+
connection.prepare(json: {});
338+
composeBoxController.contentController.selection =
339+
const TextSelection(baseOffset: 0, extentOffset: 2);
340+
checkTypingRequest(TypingOp.start, narrow);
341+
342+
// Ensures that a "typing stopped" notice is sent when the test ends.
343+
connection.prepare(json: {});
344+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
345+
checkTypingRequest(TypingOp.stop, narrow);
346+
});
347+
});
348+
209349
group('message-send request response', () {
210350
Future<void> setupAndTapSend(WidgetTester tester, {
211351
required void Function(int messageId) prepareResponse,
@@ -216,8 +356,6 @@ void main() {
216356
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
217357
await prepareComposeBox(tester, narrow: const TopicNarrow(123, 'some topic'));
218358

219-
final contentInputFinder = find.byWidgetPredicate(
220-
(widget) => widget is TextField && widget.controller is ComposeContentController);
221359
await tester.enterText(contentInputFinder, 'hello world');
222360

223361
prepareResponse(456);

0 commit comments

Comments
 (0)