Skip to content

Commit ecfd833

Browse files
committed
compose_box: Update typing status on focus and content changes
The new test helper prepareComposeBoxWithNavigation is needed, because it allows navigation by setting up the heavier ZulipApp instead. This currently does not listen on content change only. When the selection within the compose box changes, a typing notification is also sent. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 953dece commit ecfd833

File tree

2 files changed

+181
-5
lines changed

2 files changed

+181
-5
lines changed

lib/widgets/compose_box.dart

+56-5
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ class _StreamContentInputState extends State<_StreamContentInput> {
345345
super.initState();
346346
_topicTextNormalized = widget.topicController.textNormalized;
347347
widget.topicController.addListener(_topicChanged);
348+
widget.controller.addListener(_contentChanged);
349+
widget.focusNode.addListener(_focusChanged);
348350
}
349351

350352
@override
@@ -359,9 +361,26 @@ class _StreamContentInputState extends State<_StreamContentInput> {
359361
@override
360362
void dispose() {
361363
widget.topicController.removeListener(_topicChanged);
364+
widget.controller.removeListener(_contentChanged);
365+
widget.focusNode.removeListener(_focusChanged);
362366
super.dispose();
363367
}
364368

369+
void _contentChanged() {
370+
final store = PerAccountStoreWidget.of(context);
371+
store.typingNotifier.handleTypingStatusUpdate(store,
372+
destination: widget.controller.text.isNotEmpty
373+
? TopicNarrow(widget.narrow.streamId, _topicTextNormalized)
374+
: null);
375+
}
376+
377+
void _focusChanged() {
378+
if (widget.focusNode.hasFocus) return;
379+
380+
final store = PerAccountStoreWidget.of(context);
381+
store.typingNotifier.handleTypingStatusUpdate(store, destination: null);
382+
}
383+
365384
@override
366385
Widget build(BuildContext context) {
367386
final store = PerAccountStoreWidget.of(context);
@@ -408,7 +427,7 @@ class _TopicInput extends StatelessWidget {
408427
}
409428
}
410429

411-
class _FixedDestinationContentInput extends StatelessWidget {
430+
class _FixedDestinationContentInput extends StatefulWidget {
412431
const _FixedDestinationContentInput({
413432
required this.narrow,
414433
required this.controller,
@@ -419,9 +438,41 @@ class _FixedDestinationContentInput extends StatelessWidget {
419438
final ComposeContentController controller;
420439
final FocusNode focusNode;
421440

441+
@override
442+
State<_FixedDestinationContentInput> createState() => _FixedDestinationContentInputState();
443+
}
444+
445+
class _FixedDestinationContentInputState extends State<_FixedDestinationContentInput> {
446+
@override
447+
void initState() {
448+
super.initState();
449+
widget.controller.addListener(_contentChanged);
450+
widget.focusNode.addListener(_focusChanged);
451+
}
452+
453+
@override
454+
void dispose() {
455+
widget.controller.removeListener(_contentChanged);
456+
widget.focusNode.removeListener(_focusChanged);
457+
super.dispose();
458+
}
459+
460+
void _contentChanged() {
461+
final store = PerAccountStoreWidget.of(context);
462+
store.typingNotifier.handleTypingStatusUpdate(store,
463+
destination: widget.controller.text.isNotEmpty ? widget.narrow : null);
464+
}
465+
466+
void _focusChanged() {
467+
if (widget.focusNode.hasFocus) return;
468+
469+
final store = PerAccountStoreWidget.of(context);
470+
store.typingNotifier.handleTypingStatusUpdate(store, destination: null);
471+
}
472+
422473
String _hintText(BuildContext context) {
423474
final zulipLocalizations = ZulipLocalizations.of(context);
424-
switch (narrow) {
475+
switch (widget.narrow) {
425476
case TopicNarrow(:final streamId, :final topic):
426477
final store = PerAccountStoreWidget.of(context);
427478
final streamName = store.streams[streamId]?.name
@@ -445,9 +496,9 @@ class _FixedDestinationContentInput extends StatelessWidget {
445496
@override
446497
Widget build(BuildContext context) {
447498
return _ContentInput(
448-
narrow: narrow,
449-
controller: controller,
450-
focusNode: focusNode,
499+
narrow: widget.narrow,
500+
controller: widget.controller,
501+
focusNode: widget.focusNode,
451502
hintText: _hintText(context));
452503
}
453504
}

test/widgets/compose_box_test.dart

+125
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ import 'package:zulip/model/localizations.dart';
1515
import 'package:zulip/model/narrow.dart';
1616
import 'package:zulip/model/store.dart';
1717
import 'package:zulip/model/typing_status.dart';
18+
import 'package:zulip/widgets/app.dart';
1819
import 'package:zulip/widgets/compose_box.dart';
20+
import 'package:zulip/widgets/page.dart';
1921

2022
import '../api/fake_api.dart';
2123
import '../example_data.dart' as eg;
2224
import '../flutter_checks.dart';
2325
import '../model/binding.dart';
2426
import '../model/test_store.dart';
27+
import '../model/typing_status_test.dart';
2528
import '../stdlib_checks.dart';
2629
import 'dialog_checks.dart';
2730
import 'test_app.dart';
@@ -201,6 +204,128 @@ void main() {
201204
});
202205
});
203206

207+
group('ComposeBox typing notification', () {
208+
const narrow = TopicNarrow(123, 'some topic');
209+
210+
// This uses a high feature level to test with the latest version of the
211+
// setTypingNotifier API.
212+
final account = eg.account(
213+
user: eg.selfUser, zulipFeatureLevel: eg.futureZulipFeatureLevel);
214+
215+
final contentInputFinder = find.byWidgetPredicate(
216+
(widget) => widget is TextField && widget.controller is ComposeContentController);
217+
final topicInputFinder = find.byWidgetPredicate(
218+
(widget) => widget is TextField && widget.controller is ComposeTopicController);
219+
220+
void checkTypingRequest(TypingOp op, SendableNarrow narrow) =>
221+
checkSetTypingStatusRequests(connection, [(op, narrow)]);
222+
223+
Future<void> checkStartTyping(WidgetTester tester, SendableNarrow narrow) async {
224+
connection.prepare(json: {});
225+
await tester.enterText(contentInputFinder, 'hello world');
226+
checkTypingRequest(TypingOp.start, narrow);
227+
}
228+
229+
testWidgets('smoke TopicNarrow', (tester) async {
230+
await prepareComposeBox(tester, narrow: narrow, account: account);
231+
232+
await checkStartTyping(tester, narrow);
233+
234+
connection.prepare(json: {});
235+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
236+
checkTypingRequest(TypingOp.stop, narrow);
237+
});
238+
239+
testWidgets('smoke DmNarrow', (tester) async {
240+
final narrow = DmNarrow.withUsers(
241+
[eg.otherUser.userId], selfUserId: eg.selfUser.userId);
242+
await prepareComposeBox(tester, narrow: narrow, account: account);
243+
244+
await checkStartTyping(tester, narrow);
245+
246+
connection.prepare(json: {});
247+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
248+
checkTypingRequest(TypingOp.stop, narrow);
249+
});
250+
251+
testWidgets('smoke ChannelNarrow', (tester) async {
252+
const narrow = ChannelNarrow(123);
253+
final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
254+
await prepareComposeBox(tester, narrow: narrow, account: account);
255+
256+
await tester.enterText(topicInputFinder, destinationNarrow.topic);
257+
// Remove an irrelevant topic request.
258+
check(connection.takeRequests()).single
259+
..method.equals('GET')
260+
..url.path.equals('/api/v1/users/me/123/topics');
261+
262+
await checkStartTyping(tester, destinationNarrow);
263+
264+
connection.prepare(json: {});
265+
await tester.pump(store.typingNotifier.typingStoppedWaitPeriod);
266+
checkTypingRequest(TypingOp.stop, destinationNarrow);
267+
});
268+
269+
testWidgets('clearing text stops typing notification', (tester) async {
270+
await prepareComposeBox(tester, narrow: narrow, account: account);
271+
272+
await checkStartTyping(tester, narrow);
273+
274+
connection.prepare(json: {});
275+
await tester.enterText(contentInputFinder, '');
276+
checkTypingRequest(TypingOp.stop, narrow);
277+
});
278+
279+
Future<void> prepareComposeBoxWithNavigation(WidgetTester tester) async {
280+
addTearDown(testBinding.reset);
281+
await testBinding.globalStore.add(account, eg.initialSnapshot(
282+
zulipFeatureLevel: account.zulipFeatureLevel));
283+
284+
await tester.pumpWidget(const ZulipApp());
285+
await tester.pump();
286+
final navigator = await ZulipApp.navigator;
287+
navigator.push(MaterialAccountWidgetRoute(
288+
accountId: account.id, page: const ComposeBox(narrow: narrow)));
289+
await tester.pumpAndSettle();
290+
291+
store = await testBinding.globalStore.perAccount(account.id);
292+
connection = store.connection as FakeApiConnection;
293+
}
294+
295+
testWidgets('navigating away stops typing notification', (tester) async {
296+
await prepareComposeBoxWithNavigation(tester);
297+
298+
await checkStartTyping(tester, narrow);
299+
300+
connection.prepare(json: {});
301+
(await ZulipApp.navigator).pop();
302+
await tester.pump(Duration.zero);
303+
checkTypingRequest(TypingOp.stop, narrow);
304+
});
305+
306+
testWidgets('unfocusing content input stops typing notification', (tester) async {
307+
const narrow = ChannelNarrow(123);
308+
final destinationNarrow = TopicNarrow(narrow.streamId, 'test topic');
309+
await prepareComposeBox(tester, narrow: narrow, account: account);
310+
311+
await tester.enterText(topicInputFinder, destinationNarrow.topic);
312+
// Remove an irrelevant topic request.
313+
check(connection.takeRequests()).single
314+
..method.equals('GET')
315+
..url.path.equals('/api/v1/users/me/123/topics');
316+
317+
connection.prepare(json: {});
318+
await tester.enterText(contentInputFinder, 'hello world');
319+
checkTypingRequest(TypingOp.start, destinationNarrow);
320+
321+
connection.prepare(json: {});
322+
// Move focus to the topic input
323+
await tester.tap(topicInputFinder);
324+
await tester.pump(Duration.zero);
325+
checkTypingRequest(TypingOp.stop, destinationNarrow);
326+
});
327+
});
328+
204329
group('message-send request response', () {
205330
Future<void> setupAndTapSend(WidgetTester tester, {
206331
required void Function(int messageId) prepareResponse,

0 commit comments

Comments
 (0)